From d5541c0525afe7795fa0f96896053b5843c36fb9 Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 10:28:05 +0100 Subject: [PATCH 01/21] refactor(CamundaCloudTokenProvider): moved access token into misc namespace and folder --- Client/Impl/Misc/AccessToken.cs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Client/Impl/Misc/AccessToken.cs diff --git a/Client/Impl/Misc/AccessToken.cs b/Client/Impl/Misc/AccessToken.cs new file mode 100644 index 00000000..fbe2352f --- /dev/null +++ b/Client/Impl/Misc/AccessToken.cs @@ -0,0 +1,32 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace Zeebe.Client.Impl.Misc; + +public class AccessToken +{ + public string Token { get; set; } + public long DueDate { get; set; } + + public AccessToken(string token, long dueDate) + { + Token = token; + DueDate = dueDate; + } + + public override string ToString() + { + return $"{nameof(Token)}: {Token}, {nameof(DueDate)}: {DueDate}"; + } + + public static AccessToken FromJson(string result) + { + var jsonResult = JObject.Parse(result); + var accessToken = (string)jsonResult["access_token"]; + + var expiresInMilliSeconds = (long)jsonResult["expires_in"] * 1_000L; + var dueDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + expiresInMilliSeconds; + var token = new AccessToken(accessToken, dueDate); + return token; + } +} \ No newline at end of file From 46714f3e2b63e4c4283ecb7171eea8ad2dabe98e Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 10:33:04 +0100 Subject: [PATCH 02/21] refactor(CamundaCloudTokenProvider): created a base client for token providing --- Client/Impl/Builder/BaseTokenProvider.cs | 86 ++++++++++++ .../Impl/Builder/CamundaCloudTokenProvider.cs | 122 ++---------------- 2 files changed, 99 insertions(+), 109 deletions(-) create mode 100644 Client/Impl/Builder/BaseTokenProvider.cs diff --git a/Client/Impl/Builder/BaseTokenProvider.cs b/Client/Impl/Builder/BaseTokenProvider.cs new file mode 100644 index 00000000..55d135e0 --- /dev/null +++ b/Client/Impl/Builder/BaseTokenProvider.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Zeebe.Client.Api.Builder; +using Zeebe.Client.Impl.Misc; + +namespace Zeebe.Client.Impl.Builder; + +public abstract class BaseTokenProvider : IAccessTokenSupplier +{ + protected Dictionary CachedCredentials { get; set; } + + private static readonly string ZeebeRootPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); + + private readonly string zeebeTokenFileName; + private readonly ILogger logger; + + public string TokenStoragePath { get; set; } = ZeebeRootPath; + public string Audience { get; set; } + private string TokenFileName => TokenStoragePath + Path.DirectorySeparatorChar + zeebeTokenFileName; + + public BaseTokenProvider(string cachingTokenFileName, string audience, ILogger logger = null) + { + zeebeTokenFileName = cachingTokenFileName; + Audience = audience; + this.logger = logger; + } + + public Task GetAccessTokenForRequestAsync( + string authUri = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + // check in memory + AccessToken currentAccessToken; + if (CachedCredentials.TryGetValue(Audience, out currentAccessToken)) + { + logger?.LogTrace("Use in memory access token."); + return GetValidToken(currentAccessToken); + } + + // check if token file exists + var useCachedFileToken = File.Exists(TokenFileName); + if (useCachedFileToken) + { + logger?.LogTrace("Read cached access token from {tokenFileName}", TokenFileName); + // read token + var content = File.ReadAllText(TokenFileName); + CachedCredentials = JsonConvert.DeserializeObject>(content); + if (CachedCredentials.TryGetValue(Audience, out currentAccessToken)) + { + logger?.LogTrace("Found access token in credentials file."); + return GetValidToken(currentAccessToken); + } + } + + // request token + return RequestAccessTokenAsync(); + } + + private Task GetValidToken(AccessToken currentAccessToken) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var dueDate = currentAccessToken.DueDate; + if (now < dueDate) + { + // still valid + return Task.FromResult(currentAccessToken.Token); + } + + logger?.LogTrace("Access token is no longer valid (now: {now} > dueTime: {dueTime}), request new one.", now, + dueDate); + return RequestAccessTokenAsync(); + } + + protected void WriteCredentials() + { + File.WriteAllText(TokenFileName, JsonConvert.SerializeObject(CachedCredentials)); + } + + protected abstract Task RequestAccessTokenAsync(); +} \ No newline at end of file diff --git a/Client/Impl/Builder/CamundaCloudTokenProvider.cs b/Client/Impl/Builder/CamundaCloudTokenProvider.cs index b83d82e0..025c74bb 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -1,28 +1,19 @@ using System; -using System.Collections; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Text; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Zeebe.Client.Api.Builder; +using Zeebe.Client.Impl.Misc; namespace Zeebe.Client.Impl.Builder { - public class CamundaCloudTokenProvider : IAccessTokenSupplier, IDisposable + public class CamundaCloudTokenProvider : BaseTokenProvider, IDisposable { private const string JsonContent = "{{\"client_id\":\"{0}\",\"client_secret\":\"{1}\",\"audience\":\"{2}\",\"grant_type\":\"client_credentials\"}}"; - private const string ZeebeCloudTokenFileName = "credentials"; - - private static readonly string ZeebeRootPath = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); - private readonly ILogger logger; private readonly string authServer; private readonly string clientId; @@ -36,7 +27,7 @@ internal CamundaCloudTokenProvider( string clientId, string clientSecret, string audience, - ILogger logger = null) + ILogger logger = null) : base("credentials", audience) { this.logger = logger; this.authServer = authServer; @@ -46,8 +37,7 @@ internal CamundaCloudTokenProvider( // default client handler httpClient = new HttpClient(new HttpClientHandler(), disposeHandler: false); - TokenStoragePath = ZeebeRootPath; - Credentials = new Dictionary(); + CachedCredentials = new Dictionary(); } public static CamundaCloudTokenProviderBuilder Builder() @@ -55,62 +45,12 @@ public static CamundaCloudTokenProviderBuilder Builder() return new CamundaCloudTokenProviderBuilder(); } - public string TokenStoragePath { get; set; } - private string TokenFileName => TokenStoragePath + Path.DirectorySeparatorChar + ZeebeCloudTokenFileName; - private Dictionary Credentials { get; set; } - - public Task GetAccessTokenForRequestAsync( - string authUri = null, - CancellationToken cancellationToken = default(CancellationToken)) - { - // check in memory - AccessToken currentAccessToken; - if (Credentials.TryGetValue(audience, out currentAccessToken)) - { - logger?.LogTrace("Use in memory access token."); - return GetValidToken(currentAccessToken); - } - - // check if token file exists - var tokenFileName = TokenFileName; - var existToken = File.Exists(tokenFileName); - if (existToken) - { - logger?.LogTrace("Read cached access token from {tokenFileName}", tokenFileName); - // read token - var content = File.ReadAllText(tokenFileName); - Credentials = JsonConvert.DeserializeObject>(content); - if (Credentials.TryGetValue(audience, out currentAccessToken)) - { - logger?.LogTrace("Found access token in credentials file."); - return GetValidToken(currentAccessToken); - } - } - - // request token - return RequestAccessTokenAsync(); - } - internal void SetHttpMessageHandler(HttpMessageHandler handler) { httpMessageHandler = handler; httpClient = new HttpClient(handler); } - private Task GetValidToken(AccessToken currentAccessToken) - { - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var dueDate = currentAccessToken.DueDate; - if (now < dueDate) - { - // still valid - return Task.FromResult(currentAccessToken.Token); - } - - logger?.LogTrace("Access token is no longer valid (now: {now} > dueTime: {dueTime}), request new one.", now, dueDate); - return RequestAccessTokenAsync(); - } - // Requesting the token is similar to this: // curl --request POST \ // --url https://login.cloud.[ultrawombat.com | camunda.io]/oauth/token \ @@ -129,7 +69,7 @@ private Task GetValidToken(AccessToken currentAccessToken) // // Defined here https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ - private async Task RequestAccessTokenAsync() + protected override async Task RequestAccessTokenAsync() { var directoryInfo = Directory.CreateDirectory(TokenStoragePath); if (!directoryInfo.Exists) @@ -137,54 +77,18 @@ private async Task RequestAccessTokenAsync() throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); } - var tokenFileName = TokenFileName; var json = string.Format(JsonContent, clientId, clientSecret, audience); - using (var content = new StringContent(json, Encoding.UTF8, "application/json")) - { - var httpResponseMessage = await httpClient.PostAsync(authServer, content); - - var result = await httpResponseMessage.Content.ReadAsStringAsync(); - var token = ToAccessToken(result); - logger?.LogDebug("Received access token for {audience}, will backup at {path}.", audience, tokenFileName); - Credentials[audience] = token; - WriteCredentials(); - - return token.Token; - } - } - - private void WriteCredentials() - { - File.WriteAllText(TokenFileName, JsonConvert.SerializeObject(Credentials)); - } - - private static AccessToken ToAccessToken(string result) - { - var jsonResult = JObject.Parse(result); - var accessToken = (string)jsonResult["access_token"]; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var httpResponseMessage = await httpClient.PostAsync(authServer, content); - var expiresInMilliSeconds = (long)jsonResult["expires_in"] * 1_000L; - var dueDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + expiresInMilliSeconds; - var token = new AccessToken(accessToken, dueDate); - return token; - } + var result = await httpResponseMessage.Content.ReadAsStringAsync(); + var token = AccessToken.FromJson(result); + logger?.LogDebug("Received access token for {audience}", audience); + CachedCredentials[audience] = token; + WriteCredentials(); - public class AccessToken - { - public string Token { get; set; } - public long DueDate { get; set; } - - public AccessToken(string token, long dueDate) - { - Token = token; - DueDate = dueDate; - } - - public override string ToString() - { - return $"{nameof(Token)}: {Token}, {nameof(DueDate)}: {DueDate}"; - } + return token.Token; } public void Dispose() From c754cc3bb9b86b178c0a53fd86aab1ad125b9d89 Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 11:31:43 +0100 Subject: [PATCH 03/21] feat(OAuth2TokenProvider): added a new token provider for OAuth2 --- Client/Impl/Builder/OAuth2TokenProvider.cs | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 Client/Impl/Builder/OAuth2TokenProvider.cs diff --git a/Client/Impl/Builder/OAuth2TokenProvider.cs b/Client/Impl/Builder/OAuth2TokenProvider.cs new file mode 100644 index 00000000..9290fdc8 --- /dev/null +++ b/Client/Impl/Builder/OAuth2TokenProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Zeebe.Client.Impl.Misc; + +namespace Zeebe.Client.Impl.Builder +{ + public class OAuth2TokenProvider : BaseTokenProvider, IDisposable + { + private readonly ILogger logger; + private readonly string authServer; + private readonly string clientId; + private readonly string clientSecret; + private readonly string audience; + private HttpClient httpClient; + private HttpMessageHandler httpMessageHandler; + + public OAuth2TokenProvider( + string authServer, + string clientId, + string clientSecret, + string audience, + ILogger logger = null) : base("oauth2_credentials", audience) + { + this.logger = logger; + this.authServer = authServer; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.audience = audience; + + // default client handler + httpClient = new HttpClient(new HttpClientHandler(), disposeHandler: false); + CachedCredentials = new Dictionary(); + } + + public static CamundaCloudTokenProviderBuilder Builder() + { + return new CamundaCloudTokenProviderBuilder(); + } + + internal void SetHttpMessageHandler(HttpMessageHandler handler) + { + httpMessageHandler = handler; + httpClient = new HttpClient(handler); + } + + protected override async Task RequestAccessTokenAsync() + { + var directoryInfo = Directory.CreateDirectory(TokenStoragePath); + if (!directoryInfo.Exists) + { + throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); + } + + var formContent = BuildRequestAccessTokenContent(); + + var httpResponseMessage = await httpClient.PostAsync(authServer, formContent); + + var result = await httpResponseMessage.Content.ReadAsStringAsync(); + var token = AccessToken.FromJson(result); + logger?.LogDebug("Received access token for {audience}", audience); + CachedCredentials[audience] = token; + WriteCredentials(); + + return token.Token; + } + + private FormUrlEncodedContent BuildRequestAccessTokenContent() + { + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("client_secret", clientSecret), + new KeyValuePair("audience", audience), + new KeyValuePair("grant_type", "client_credentials") + }); + return formContent; + } + + public void Dispose() + { + httpClient.Dispose(); + httpMessageHandler.Dispose(); + } + } +} \ No newline at end of file From 64561efff9bf7e9565eaa33f48c26430ce0b96c6 Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 10:35:52 +0100 Subject: [PATCH 04/21] feat(OAuth2TokenProviderBuilder): added token provider builder for OAuth2TokenProvider --- .../Builder/IOAuth2TokenProviderBuilder.cs | 63 ++++++++++++++++++ .../Builder/OAuth2TokenProviderBuilder.cs | 64 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 Client/Api/Builder/IOAuth2TokenProviderBuilder.cs create mode 100644 Client/Impl/Builder/OAuth2TokenProviderBuilder.cs diff --git a/Client/Api/Builder/IOAuth2TokenProviderBuilder.cs b/Client/Api/Builder/IOAuth2TokenProviderBuilder.cs new file mode 100644 index 00000000..24c390d1 --- /dev/null +++ b/Client/Api/Builder/IOAuth2TokenProviderBuilder.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; +using Zeebe.Client.Impl.Builder; + +namespace Zeebe.Client.Api.Builder; + +public interface IOAuth2TokenProviderBuilder +{ + /// + /// Defines the logger factory which should be used by the token provider + /// to log messages. + /// *This is optional and no messages are logged if this method is not called.* + /// + /// the factory to create an ILogger + /// the fluent IOAuth2TokenProviderBuilder + IOAuth2TokenProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory); + + /// + /// Defines the authorization server, from which the access token should be requested. + /// + /// an url, which points to the authorization server + /// the next step in building a OAuth2TokenProvider + IOAuth2TokenProviderBuilderStep2 UseAuthServer(string url); +} + +public interface IOAuth2TokenProviderBuilderStep2 +{ + /// + /// Defines the client id, which should be used to create the access token. + /// + /// the client id, which is supplied by OAuth2 + /// the next step in building a OAuth2TokenProvider + IOAuth2TokenProviderBuilderStep3 UseClientId(string clientId); +} + +public interface IOAuth2TokenProviderBuilderStep3 +{ + /// + /// Defines the client secret, which should be used to create the access token. + /// + /// the client secret, which is supplied by OAuth2 + /// the next step in building a OAuth2TokenProvider + IOAuth2TokenProviderBuilderStep4 UseClientSecret(string clientSecret); +} + +public interface IOAuth2TokenProviderBuilderStep4 +{ + /// + /// Defines the audience for which the token provider should create tokens. + /// + /// the audience, which is normally a domain name + /// the next step in building a OAuth2TokenProvider + IOAuth2TokenProviderBuilderFinalStep UseAudience(string audience); +} + +public interface IOAuth2TokenProviderBuilderFinalStep +{ + /// + /// Builds the OAuth2TokenProvider, which can be used by the ZeebeClient to + /// communicate with a Self-Hosted Zeebe gateway, which uses identity. + /// + /// the OAuth2TokenProvider + OAuth2TokenProvider Build(); +} \ No newline at end of file diff --git a/Client/Impl/Builder/OAuth2TokenProviderBuilder.cs b/Client/Impl/Builder/OAuth2TokenProviderBuilder.cs new file mode 100644 index 00000000..288e83f6 --- /dev/null +++ b/Client/Impl/Builder/OAuth2TokenProviderBuilder.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.Extensions.Logging; +using Zeebe.Client.Api.Builder; + +namespace Zeebe.Client.Impl.Builder; + +public class OAuth2TokenProviderBuilder : IOAuth2TokenProviderBuilder, + IOAuth2TokenProviderBuilderStep2, + IOAuth2TokenProviderBuilderStep3, + IOAuth2TokenProviderBuilderStep4, + IOAuth2TokenProviderBuilderFinalStep +{ + private ILoggerFactory loggerFactory; + private string audience; + private string authServer = "https://login.cloud.camunda.io/oauth/token"; + private string clientId; + private string clientSecret; + + /// + public IOAuth2TokenProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) + { + this.loggerFactory = loggerFactory; + return this; + } + + /// + public IOAuth2TokenProviderBuilderStep2 UseAuthServer(string url) + { + authServer = url ?? throw new ArgumentNullException(nameof(url)); + return this; + } + + /// + public IOAuth2TokenProviderBuilderStep3 UseClientId(string clientId) + { + this.clientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); + return this; + } + + /// + public IOAuth2TokenProviderBuilderStep4 UseClientSecret(string clientSecret) + { + this.clientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); + return this; + } + + /// + public IOAuth2TokenProviderBuilderFinalStep UseAudience(string audience) + { + this.audience = audience ?? throw new ArgumentNullException(nameof(audience)); + return this; + } + + /// + public OAuth2TokenProvider Build() + { + return new OAuth2TokenProvider( + authServer, + clientId, + clientSecret, + audience, + loggerFactory?.CreateLogger()); + } +} \ No newline at end of file From fceb5ddd68fc89ae93b186251e74aa454bed4d64 Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 10:37:28 +0100 Subject: [PATCH 05/21] refactor(CamundaCloudTokenProviderTest): added import for moved access token class --- Client.UnitTests/CamundaCloudTokenProviderTest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Client.UnitTests/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/CamundaCloudTokenProviderTest.cs index ee16cc5b..32e1abc3 100644 --- a/Client.UnitTests/CamundaCloudTokenProviderTest.cs +++ b/Client.UnitTests/CamundaCloudTokenProviderTest.cs @@ -7,9 +7,9 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using NLog; using NUnit.Framework; using Zeebe.Client.Impl.Builder; +using Zeebe.Client.Impl.Misc; namespace Zeebe.Client { @@ -128,7 +128,7 @@ public async Task ShouldStoreCredentials() Assert.AreEqual(1, files.Length); var tokenFile = files[0]; var content = File.ReadAllText(tokenFile); - var credentials = JsonConvert.DeserializeObject>(content); + var credentials = JsonConvert.DeserializeObject>(content); Assert.AreEqual(credentials["AUDIENCE"].Token, token); } @@ -156,7 +156,7 @@ public async Task ShouldStoreMultipleCredentials() Assert.AreEqual(1, files.Length); var tokenFile = files[0]; var content = File.ReadAllText(tokenFile); - var credentials = JsonConvert.DeserializeObject>(content); + var credentials = JsonConvert.DeserializeObject>(content); Assert.AreEqual(credentials.Count, 2); Assert.AreEqual(token, credentials["OTHER_AUDIENCE"].Token); From ecc236d21397bba595058deb1eec481b8426fc35 Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 10:45:55 +0100 Subject: [PATCH 06/21] test(OAuth2TokenProvider): added unit test for OAuth2 token provider --- Client.UnitTests/OAuth2TokenProviderTest.cs | 338 ++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 Client.UnitTests/OAuth2TokenProviderTest.cs diff --git a/Client.UnitTests/OAuth2TokenProviderTest.cs b/Client.UnitTests/OAuth2TokenProviderTest.cs new file mode 100644 index 00000000..13eae313 --- /dev/null +++ b/Client.UnitTests/OAuth2TokenProviderTest.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Zeebe.Client.Impl.Builder; +using Zeebe.Client.Impl.Misc; + +namespace Zeebe.Client +{ + [TestFixture] + public class OAuth2TokenProviderTest + { + private HttpMessageHandlerStub MessageHandlerStub { get; set; } + private OAuth2TokenProvider TokenProvider { get; set; } + private string TokenStoragePath { get; set; } + private static long ExpiresIn { get; set; } + private static string Token { get; set; } + + private static string _requestUri; + private static string _clientId; + private static string _clientSecret; + private static string _audience; + + [SetUp] + public void Init() + { + _requestUri = "https://local.de"; + _clientId = "ID"; + _clientSecret = "SECRET"; + _audience = "AUDIENCE"; + TokenProvider = new OAuth2TokenProviderBuilder() + .UseAuthServer(_requestUri) + .UseClientId(_clientId) + .UseClientSecret(_clientSecret) + .UseAudience(_audience) + .Build(); + + MessageHandlerStub = new HttpMessageHandlerStub(); + TokenProvider.SetHttpMessageHandler(MessageHandlerStub); + TokenStoragePath = Path.GetTempPath() + ".zeebe/"; + TokenProvider.TokenStoragePath = TokenStoragePath; + ExpiresIn = 3600; + Token = "REQUESTED_TOKEN"; + } + + [TearDown] + public void CleanUp() + { + Directory.Delete(TokenStoragePath, true); + TokenProvider.Dispose(); + } + + private class HttpMessageHandlerStub : HttpMessageHandler + { + public int RequestCount { get; set; } + private bool _disposed = false; + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + CheckDisposed(); + Assert.AreEqual(request.RequestUri, _requestUri); + var content = await request.Content.ReadAsStringAsync(cancellationToken); + + var formFields = GetFormFields(content); + Assert.AreEqual(formFields["client_id"], _clientId); + Assert.AreEqual(formFields["client_secret"], _clientSecret); + Assert.AreEqual(formFields["audience"], _audience); + + RequestCount++; + var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(@"{ + ""access_token"":""" + Token + @""", + ""token_type"":""bearer"", + ""expires_in"": " + ExpiresIn + @", + ""refresh_token"":""IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk"", + ""scope"":""create""}"), + }; + + return responseMessage; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _disposed = true; + } + + private void CheckDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException("HttpMessageHandlerStub"); + } + } + } + + [Test] + public async Task ShouldRequestCredentials() + { + // given + + // when + var token = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("REQUESTED_TOKEN", token); + Assert.AreEqual(1, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldStoreCredentials() + { + // given + + // when + var token = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("REQUESTED_TOKEN", token); + var files = Directory.GetFiles(TokenStoragePath); + Assert.AreEqual(1, files.Length); + var tokenFile = files[0]; + var content = File.ReadAllText(tokenFile); + var credentials = JsonConvert.DeserializeObject>(content); + Assert.AreEqual(credentials["AUDIENCE"].Token, token); + } + + [Test] + public async Task ShouldStoreMultipleCredentials() + { + // given + await TokenProvider.GetAccessTokenForRequestAsync(); + var otherProvider = new OAuth2TokenProviderBuilder() + .UseAuthServer(_requestUri) + .UseClientId(_clientId = "OTHERID") + .UseClientSecret(_clientSecret = "OTHERSECRET") + .UseAudience(_audience = "OTHER_AUDIENCE") + .Build(); + otherProvider.SetHttpMessageHandler(MessageHandlerStub); + otherProvider.TokenStoragePath = TokenStoragePath; + Token = "OTHER_TOKEN"; + + // when + var token = await otherProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("OTHER_TOKEN", token); + var files = Directory.GetFiles(TokenStoragePath); + Assert.AreEqual(1, files.Length); + var tokenFile = files[0]; + var content = File.ReadAllText(tokenFile); + var credentials = JsonConvert.DeserializeObject>(content); + + Assert.AreEqual(credentials.Count, 2); + Assert.AreEqual(token, credentials["OTHER_AUDIENCE"].Token); + Assert.AreEqual("REQUESTED_TOKEN", credentials["AUDIENCE"].Token); + } + + [Test] + public async Task ShouldGetTokenFromInMemory() + { + // given + await TokenProvider.GetAccessTokenForRequestAsync(); + var files = Directory.GetFiles(TokenStoragePath); + var tokenFile = files[0]; + File.WriteAllText(tokenFile, "FILE_TOKEN"); + + // when + var token = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("REQUESTED_TOKEN", token); + Assert.AreEqual(1, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldExpireInOneSecond() + { + // given + ExpiresIn = 1; + var firstToken = await TokenProvider.GetAccessTokenForRequestAsync(); + var files = Directory.GetFiles(TokenStoragePath); + var tokenFile = files[0]; + File.WriteAllText(tokenFile, "FILE_TOKEN"); + + // when + Token = "NEW_TOKEN"; + var secondToken = await TokenProvider.GetAccessTokenForRequestAsync(); + Thread.Sleep(1_000); + var thirdToken = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("REQUESTED_TOKEN", firstToken); + Assert.AreEqual(secondToken, firstToken); + Assert.AreEqual("NEW_TOKEN", thirdToken); + Assert.AreEqual(2, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldRequestNewTokenWhenExpired() + { + // given + ExpiresIn = 0; + var firstToken = await TokenProvider.GetAccessTokenForRequestAsync(); + var files = Directory.GetFiles(TokenStoragePath); + var tokenFile = files[0]; + File.WriteAllText(tokenFile, "FILE_TOKEN"); + + // when + Token = "SECOND_TOKEN"; + var secondToken = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("REQUESTED_TOKEN", firstToken); + Assert.AreNotEqual(secondToken, firstToken); + Assert.AreEqual("SECOND_TOKEN", secondToken); + Assert.AreEqual(2, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldUseCachedFile() + { + // given + Token = "STORED_TOKEN"; + await TokenProvider.GetAccessTokenForRequestAsync(); + // re-init the TokenProvider + Init(); + + // when + var token = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("STORED_TOKEN", token); + Assert.AreEqual(0, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldNotUseCachedFileForOtherAudience() + { + // given + Token = "STORED_TOKEN"; + await TokenProvider.GetAccessTokenForRequestAsync(); + var otherProvider = new OAuth2TokenProviderBuilder() + .UseAuthServer(_requestUri) + .UseClientId(_clientId = "OTHERID") + .UseClientSecret(_clientSecret = "OTHERSECRET") + .UseAudience(_audience = "OTHER_AUDIENCE") + .Build(); + otherProvider.SetHttpMessageHandler(MessageHandlerStub); + otherProvider.TokenStoragePath = TokenStoragePath; + Token = "OTHER_TOKEN"; + + // when + var token = await otherProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("OTHER_TOKEN", token); + } + + [Test] + public async Task ShouldRequestWhenCachedFileExpired() + { + // given + ExpiresIn = 0; + Token = "STORED_TOKEN"; + await TokenProvider.GetAccessTokenForRequestAsync(); + // re-init the TokenProvider + Init(); + + // when + var token = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("REQUESTED_TOKEN", token); + Assert.AreEqual(1, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldUseCachedFileAndAfterwardsInMemory() + { + // given + Token = "STORED_TOKEN"; + await TokenProvider.GetAccessTokenForRequestAsync(); + // re-init the TokenProvider + Init(); + + // when + await TokenProvider.GetAccessTokenForRequestAsync(); + var files = Directory.GetFiles(TokenStoragePath); + var tokenFile = files[0]; + File.WriteAllText(tokenFile, "FILE_TOKEN"); + var token = await TokenProvider.GetAccessTokenForRequestAsync(); + + // then + Assert.AreEqual("STORED_TOKEN", token); + Assert.AreEqual(0, MessageHandlerStub.RequestCount); + } + + [Test] + public async Task ShouldNotThrowObjectDisposedExceptionWhenTokenExpires() + { + // given + ExpiresIn = 0; + await TokenProvider.GetAccessTokenForRequestAsync(); + + // when + Assert.DoesNotThrowAsync(async () => await TokenProvider.GetAccessTokenForRequestAsync()); + + // then + Assert.AreEqual(2, MessageHandlerStub.RequestCount); + } + + private static Dictionary GetFormFields(string content) + { + var formFields = new Dictionary(); + var keyValuePairs = content.Split('&'); + foreach (var keyValuePair in keyValuePairs) + { + var pair = keyValuePair.Split('='); + if (pair.Length == 2) + { + var key = Uri.UnescapeDataString(pair[0]); + var value = Uri.UnescapeDataString(pair[1]); + formFields.Add(key, value); + } + } + return formFields; + } + } +} \ No newline at end of file From 34e77013b24d6d63c6073433549929138948267f Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 11:29:49 +0100 Subject: [PATCH 07/21] refactor(ZeebeIntegrationTestHelper): added ability to start integration tests with camunda identity stack --- .../Client.IntegrationTests.csproj | 9 + .../Resources/Broker/chain.cert.pem | 22 +++ .../Resources/Broker/private.key.pem | 28 +++ Client.IntegrationTests/Resources/server.crt | 20 ++ .../ZeebeIntegrationTestHelper.cs | 184 ++++++++++++++++-- 5 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 Client.IntegrationTests/Resources/Broker/chain.cert.pem create mode 100644 Client.IntegrationTests/Resources/Broker/private.key.pem create mode 100644 Client.IntegrationTests/Resources/server.crt diff --git a/Client.IntegrationTests/Client.IntegrationTests.csproj b/Client.IntegrationTests/Client.IntegrationTests.csproj index 40f33367..4a73dd9e 100644 --- a/Client.IntegrationTests/Client.IntegrationTests.csproj +++ b/Client.IntegrationTests/Client.IntegrationTests.csproj @@ -32,6 +32,15 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/Client.IntegrationTests/Resources/Broker/chain.cert.pem b/Client.IntegrationTests/Resources/Broker/chain.cert.pem new file mode 100644 index 00000000..e20e7f03 --- /dev/null +++ b/Client.IntegrationTests/Resources/Broker/chain.cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUPlH7wI1Bq6F7k63ZNtSXR0DgjxwwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCREUxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB0Nh +bXVuZGExDjAMBgNVBAsMBXplZWJlMRIwEAYDVQQDDAlsb2NhbGhvc3QwIBcNMjAw +NzA2MTA1MTM3WhgPMjEyMDA2MTIxMDUxMzdaMFUxCzAJBgNVBAYTAkRFMRAwDgYD +VQQIDAdFbmdsYW5kMRAwDgYDVQQKDAdDYW11bmRhMQ4wDAYDVQQLDAV6ZWViZTES +MBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA0ALY6dhCih7lbyOI79N1HTi7vapsw7DISr+btQgZArsPt/xnU2tTvCH7F7mJ +OQiz1T5cUrNqTi9ZSkP6nDoGsFDZkQRZkJc+fF3OjIUnZ62OGyD7LV62tKisojx4 +ulNYW+a7oVu+hQtP2ht3Hsi30fgt9P1Nq+0c11BQzNQfFwo74hFQTVCbYHQf3uU7 +W08o0rYCCRIN+rJXUdsD1pm5snFmg7o3nQSUGFYpDHezoZZzL/d4a3YiPAG//YBH +qT/GerhHwa5fK5PFPryns+oq+4PgaF8JB3qVQC6rJWhnmT32n0cQ4OxNTjX5+8JC +jKH3rGkPDSXf3zJaAbU0GWNzEQIDAQABo3oweDAdBgNVHQ4EFgQUgtu9rM/iDT8J +6FvuokdJ3E6gRc4wHwYDVR0jBBgwFoAUgtu9rM/iDT8J6FvuokdJ3E6gRc4wDwYD +VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwFQYDVR0RBA4wDIcEfwAAAYcE +AAAAADANBgkqhkiG9w0BAQsFAAOCAQEAkinfKsHPfDEoOsJ2ic4Bc8ynCV/Fm4GS +huTOxnHiB5KqxN12s8MBd2zTpZNx2H4Pj32W9OUUWluyLvofueTYsarvUHY4TxkT +z04aFFcK5D0lPGLy7eBPSsWmiovCTvjWixxgOiRxYo+t6/ttNvXsZ0PnAZypSbfb +vAb7DeP3SXDEP+QnpIw0PpO3IaoYgilPSrfQV1n4fgFSOVa6545cUpJHINj264qF +Si8d8c1YokLcJFHepUQzTRKOgCgds+e3496iJGhQbhIDw24dMVmefYOqDepTYVlw +KxaCNRcTKljnM9QazGG8scqJaPjGDteDYBzh63+XbdPhjzWK8/yFIA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/Client.IntegrationTests/Resources/Broker/private.key.pem b/Client.IntegrationTests/Resources/Broker/private.key.pem new file mode 100644 index 00000000..98598d98 --- /dev/null +++ b/Client.IntegrationTests/Resources/Broker/private.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQAtjp2EKKHuVv +I4jv03UdOLu9qmzDsMhKv5u1CBkCuw+3/GdTa1O8IfsXuYk5CLPVPlxSs2pOL1lK +Q/qcOgawUNmRBFmQlz58Xc6MhSdnrY4bIPstXra0qKyiPHi6U1hb5ruhW76FC0/a +G3ceyLfR+C30/U2r7RzXUFDM1B8XCjviEVBNUJtgdB/e5TtbTyjStgIJEg36sldR +2wPWmbmycWaDujedBJQYVikMd7OhlnMv93hrdiI8Ab/9gEepP8Z6uEfBrl8rk8U+ +vKez6ir7g+BoXwkHepVALqslaGeZPfafRxDg7E1ONfn7wkKMofesaQ8NJd/fMloB +tTQZY3MRAgMBAAECggEAFnSoPB53mHebZzMb3mAinYP5aJFUao/UH5Wt1o5IPO46 +1S7vbKcChCXa+IW0Fa8l0tiHmPn7ePNNnWHXVTRCcphX1Hr2vFBHk5+A49SgG2Y2 +GCGoXA6EhN5MvLrwgZTrzggLq3C/EZfWCAK9Clq61XUIaRFLaEsRuQDXqDUiIhdr +5FzoKy/94BhOpqRrF/RQUw8VtUtUSzHaUCBZiFTfBWSKKdYVLNMhu6fsdJOBiXTU +vOpP/IRk4L8vqlZJqRQHu76OcUxd7OA6MsqFjQsYTNvR0y5xsV9T2syKze3cDUPj +oO4fnu/Z2ZvD34/d2lzcjZWjQ5d6vSOYZN2DYBWV0QKBgQDuEySRojaQvPj1aCW5 +/mWmgtpCOgScWXgdHagsMWTMEpfqFIBRWeYKyW80eBjRyOHJ+JisHYWFxEXu0ZoX +yqhBpHma0YPsH+Xm778KrbS74aYrW7inIGhYtUX0+n+YQ6q9aroFBq4erTJu/Ev6 +4PX1yJPWbHWA3WVi97NWBmmF7QKBgQDfrDtaU9IbIp/LA1HFA1e28iCwDh6S3Rsz +E5K0mEZInfKdGESJfYuAaik0LCmwIaOJ/7fHBx1R7JGTYwt+FeROwBcC6n3qzhbD +K4ptmmTbwzOebz2r1SjX1rMaBIfWkQbxYxecL/HzYSnYF/fEPjCyO8CjXIcum2N4 +V9eGFv59NQKBgH7knyIsdq7wujV7XFhlWuLEbfbMm7aGDXpfW0qqzRHkeyod4UL7 +Cp0HPomV1YzDaG1RXnamiYuB0NB40YwKzWGne9VkBM+vNMfBU28qpOFbZUlI6wPR +Ryy4+d+YQLf0oSWypBGXvOjG4dG8EfdXPmHRldK9HmggGTEF24Vnh4kFAoGBAIuf +adVy6X8C2BjUU6DV+1U6Q+lihvdKioYRu8x8GbOO1Tn3QiFJe2GH43yr7MID3aBx +PnlBGa5gLGeCtlPYupHmGvc5Ba0jRNZEQb81V6xPZ9OIwUiYYUyKu3aMSXdJRLo+ +DyjyTOiOSJ6aJ5Ia+C7qWdAgHEqduTQQMXuEswvZAoGBAN5kC+RjAsEQ5myY7evz +1R/3uk5uwUk/m1GadMCHoWJs7uLdwPa0ThC0Bt9yQxltWseGBj8ysKuWzoy2O11f +gMqZF61NuZibVk+eFc2T9IgPkYXYBSRluafunqEgvclUnuCwUrfwEAN07bHfSuGD +bNGJygSW2596II+xg/TRPhnF +-----END PRIVATE KEY----- diff --git a/Client.IntegrationTests/Resources/server.crt b/Client.IntegrationTests/Resources/server.crt new file mode 100644 index 00000000..80563574 --- /dev/null +++ b/Client.IntegrationTests/Resources/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjMCFBDDfC44PsguTshkcc2CVESnk/UyMA0GCSqGSIb3DQEBCwUAMGIx +CzAJBgNVBAYTAkRFMRAwDgYDVQQIDAdHZXJtYW55MQ8wDQYDVQQHDAZCZXJsaW4x +ETAPBgNVBAoMCFplZWJlLmlvMQ4wDAYDVQQLDAVaZWViZTENMAsGA1UEAwwEemVs +bDAeFw0xOTA5MDUwNjE5MjFaFw0yOTA5MDIwNjE5MjFaMGIxCzAJBgNVBAYTAkRF +MRAwDgYDVQQIDAdHZXJtYW55MQ8wDQYDVQQHDAZCZXJsaW4xETAPBgNVBAoMCFpl +ZWJlLmlvMQ4wDAYDVQQLDAVaZWViZTENMAsGA1UEAwwEemVsbDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAMncS2Y4ddY/7PNu2kW0DYdSvLcv5ONIec0V +E0+Dgls7ElVZJxqOwbIDB8Q4sOT335xpY4rtFK6lUrh541fCkVdtajI+Bv1pzFU+ +9LxMPU5GD/uFScVFA2MJy9ps5L6naFYPF3ZkE3s3gb6APTFc5ou5xZTOZ5To0s/Z +ay1RQ92inxjAyvArL9IZSryB6Xq4qyBLUm8wZYZaQ+arLQ8pBADapWkgMY8tKIlA +b185hbb2nY1ns/zws+dsHA5NIk6p7yZ+D3/SXKL/0fDBcPKmgJ+3hkgBpajMtpuw +rLoWyppQfDFGCODiV/Pd1KUrIpbrZorIlkpd3s11fUooqkrtSwMCAwEAATANBgkq +hkiG9w0BAQsFAAOCAQEAmk8VLv7nzOGCvU0gwJI/Sa6JkGdiQ7Jz0cfQGWkqfN6D +Yhw/pFlFelgrD5kGLG8jAGNT4kWiaqGtoFpUkHxADDojJZ63Tk8xUI/o9xFWZkEY +B0pxL39ybzQyuhX/dqkxZdLiNzJV5GTiSCtuTW+N8+WO5CFzT52rxYWQlPP31R5n +p4wzIVqbK/XEYqyyvZyQ5XrM9FIV/57OSXNp5kUXT9RX3HHjp6oaeKOYw6arpcrg +y9LIvuGV4h48ougO0696CgupMgYONKvDI+avRVqxJX/wr0+u56dlQ9+0XhpxCl8G +Sr53Syfz5AD3WMLZt03iO4IfW5MVnq5LRLhPCVqiQg== +-----END CERTIFICATE----- diff --git a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs index 1e8671f9..34cd6922 100644 --- a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs +++ b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs @@ -1,14 +1,16 @@ using System; using System.IO; -using System.Threading; +using System.Net; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Images; +using DotNet.Testcontainers.Networks; using Microsoft.Extensions.Logging; using NLog.Extensions.Logging; using Zeebe.Client; +using Zeebe.Client.Impl.Builder; +using IContainer = DotNet.Testcontainers.Containers.IContainer; namespace Client.IntegrationTests { @@ -18,22 +20,30 @@ public class ZeebeIntegrationTestHelper private const ushort ZeebePort = 26500; - private IContainer container; + + private IContainer zeebeContainer; private IZeebeClient client; private readonly string version; + private readonly string audience; + private readonly bool withIdentity; private int count = 1; public readonly ILoggerFactory LoggerFactory; + private IContainer postgresContainer; + private IContainer keycloakContainer; + private IContainer identityContainer; - private ZeebeIntegrationTestHelper(string version) + private ZeebeIntegrationTestHelper(string version, bool withIdentity = false) { this.version = version; + this.withIdentity = withIdentity; + audience = Guid.NewGuid().ToString(); LoggerFactory = new NLogLoggerFactory(); } - public static ZeebeIntegrationTestHelper Latest() + public static ZeebeIntegrationTestHelper Latest(bool withIdentity = false) { - return new ZeebeIntegrationTestHelper(LatestVersion); + return new ZeebeIntegrationTestHelper(LatestVersion, withIdentity); } public ZeebeIntegrationTestHelper WithPartitionCount(int count) @@ -50,10 +60,38 @@ public static ZeebeIntegrationTestHelper OfVersion(string version) public async Task SetupIntegrationTest() { TestcontainersSettings.Logger = LoggerFactory.CreateLogger(); - container = CreateZeebeContainer(); - await container.StartAsync(); - client = CreateZeebeClient(); + if (withIdentity) + { + var network = new NetworkBuilder() + .WithName(Guid.NewGuid().ToString("D")) + .Build(); + + postgresContainer = CreatePostgresContainer(network); + await postgresContainer.StartAsync(); + keycloakContainer = CreateKeyCloakContainer(network); + await keycloakContainer.StartAsync(); + + identityContainer = CreateIdentityContainer(network); + await identityContainer.StartAsync(); + zeebeContainer = CreateZeebeContainer(true, network); + } + else + { + zeebeContainer = CreateZeebeContainer(false); + } + + await zeebeContainer.StartAsync(); + + if (withIdentity) + { + client = CreateAuthenticatedZeebeClient(); + } + else + { + client = CreateZeebeClient(); + } + await AwaitBrokerReadiness(); return client; } @@ -62,24 +100,119 @@ public async Task TearDownIntegrationTest() { client.Dispose(); client = null; - await container.StopAsync(); - container = null; + if (withIdentity) + { + await postgresContainer.StopAsync(); + postgresContainer = null; + await keycloakContainer.StopAsync(); + keycloakContainer = null; + await identityContainer.StopAsync(); + identityContainer = null; + } + + await zeebeContainer.StopAsync(); + zeebeContainer = null; } - private IContainer CreateZeebeContainer() + private IContainer CreateZeebeContainer(bool withKeycloak, INetwork network = null) { - return new ContainerBuilder() + var containerBuilder = new ContainerBuilder() .WithImage(new DockerImage("camunda", "zeebe", version)) .WithPortBinding(ZeebePort, true) - .WithEnvironment("ZEEBE_BROKER_CLUSTER_PARTITIONSCOUNT", count.ToString()) + .WithEnvironment("ZEEBE_BROKER_CLUSTER_PARTITIONSCOUNT", count.ToString()); + + if (withKeycloak) + { + containerBuilder = containerBuilder.WithEnvironment("ZEEBE_BROKER_GATEWAY_SECURITY_AUTHENTICATION_MODE", + "identity") + .WithEnvironment( + "ZEEBE_BROKER_GATEWAY_SECURITY_AUTHENTICATION_IDENTITY_ISSUERBACKENDURL", + "http://integration-keycloak:8080/auth/realms/camunda-platform") + .WithEnvironment("ZEEBE_BROKER_GATEWAY_SECURITY_AUTHENTICATION_IDENTITY_AUDIENCE", + "zeebe-api") + .WithEnvironment("ZEEBE_BROKER_GATEWAY_SECURITY_ENABLED", "true") + .WithEnvironment("ZEEBE_BROKER_GATEWAY_SECURITY_CERTIFICATECHAINPATH", "/security/chain.cert.pem") + .WithEnvironment("ZEEBE_BROKER_GATEWAY_SECURITY_PRIVATEKEYPATH", "/security/private.key.pem") + .WithResourceMapping(new DirectoryInfo("./Resources/Broker"), "/security") + .WithNetwork(network); + } + + containerBuilder = containerBuilder.WithAutoRemove(true); + return containerBuilder.Build(); + } + + private IContainer CreatePostgresContainer(INetwork network) + { + var containerBuilder = new ContainerBuilder() + .WithImage("postgres") + .WithName("integration-postgres") + .WithPortBinding(5432, true) + .WithEnvironment("POSTGRES_DB", "bitnami_keycloak") + .WithEnvironment("POSTGRES_USER", "bn_keycloak") + .WithEnvironment("POSTGRES_PASSWORD", "#3]O?4RGj)DE7Z!9SA5") + .WithNetwork(network) .WithAutoRemove(true) - .Build(); + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)); + + return containerBuilder.Build(); + } + + private IContainer CreateIdentityContainer(INetwork network) + { + var containerBuilder = new ContainerBuilder() + .WithImage(new DockerImage("camunda", "identity", "8.3.0")) + .WithName("integration-identity") + .WithExposedPort("8084") + .WithPortBinding("8084", "8084") + .WithEnvironment("SERVER_PORT", "8084") + .WithEnvironment("IDENTITY_RETRY_DELAY_SECONDS", "30") + .WithEnvironment("KEYCLOAK_URL", "http://integration-keycloak:8080/auth") + .WithEnvironment("IDENTITY_AUTH_PROVIDER_BACKEND_URL", + "http://integration-keycloak:8080/auth/realms/camunda-platform") + .WithEnvironment("IDENTITY_DATABASE_HOST", "integration-postgres") + .WithEnvironment("IDENTITY_DATABASE_PORT", "5432") + .WithEnvironment("IDENTITY_DATABASE_NAME", "bitnami_keycloak") + .WithEnvironment("IDENTITY_DATABASE_USERNAME", "bn_keycloak") + .WithEnvironment("IDENTITY_DATABASE_PASSWORD", "#3]O?4RGj)DE7Z!9SA5") + .WithEnvironment("KEYCLOAK_INIT_ZEEBE_NAME", "zeebe") + .WithEnvironment("KEYCLOAK_CLIENTS_0_NAME", "zeebe") + .WithEnvironment("KEYCLOAK_CLIENTS_0_ID", "zeebe") + .WithEnvironment("KEYCLOAK_CLIENTS_0_SECRET", "sddh123865WUS)(1%!") + .WithEnvironment("KEYCLOAK_CLIENTS_0_TYPE", "M2M") + .WithEnvironment("KEYCLOAK_CLIENTS_0_PERMISSIONS_0_RESOURCE_SERVER_ID", "zeebe-api") + .WithEnvironment("KEYCLOAK_CLIENTS_0_PERMISSIONS_0_DEFINITION", "write:*") + .WithEnvironment("RESOURCE_PERMISSIONS_ENABLED", "false") + .WithAutoRemove(true) + .WithNetwork(network); + + + return containerBuilder.Build(); } - private IZeebeClient CreateZeebeClient() + private IContainer CreateKeyCloakContainer(INetwork network) + { + var containerBuilder = new ContainerBuilder() + .WithImage(new DockerImage("bitnami", "keycloak", "21.1.2")) + .WithName("integration-keycloak") + .WithPortBinding("18080", "8080") + .WithEnvironment("KEYCLOAK_HTTP_RELATIVE_PATH", "/auth") + .WithEnvironment("KEYCLOAK_DATABASE_HOST", "integration-postgres") + .WithEnvironment("KEYCLOAK_DATABASE_PASSWORD", "#3]O?4RGj)DE7Z!9SA5") + .WithEnvironment("KEYCLOAK_ADMIN_USER", "admin") + .WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", "admin") + .WithNetwork(network) + .WithAutoRemove(true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => + request.ForPort(8080).ForPath("/auth").ForStatusCode(HttpStatusCode.OK))); + + + return containerBuilder.Build(); + } + + public IZeebeClient CreateZeebeClient() { var loggerFactory = LoggerFactory; - var host = container.Hostname + ":" + container.GetMappedPublicPort(ZeebePort); + var host = zeebeContainer.Hostname + ":" + zeebeContainer.GetMappedPublicPort(ZeebePort); return ZeebeClient.Builder() .UseLoggerFactory(loggerFactory) @@ -88,9 +221,24 @@ private IZeebeClient CreateZeebeClient() .Build(); } + public IZeebeClient CreateAuthenticatedZeebeClient() + { + var loggerFactory = LoggerFactory; + var host = zeebeContainer.Hostname + ":" + zeebeContainer.GetMappedPublicPort(ZeebePort); + + return ZeebeClient.Builder() + .UseLoggerFactory(loggerFactory) + .UseGatewayAddress(host) + .UseTransportEncryption() + .AllowUntrustedCertificates() + .UseAccessTokenSupplier(new OAuth2TokenProvider( + $"http://{keycloakContainer.Hostname}:{keycloakContainer.GetMappedPublicPort(8080)}/auth/realms/camunda-platform/protocol/openid-connect/token", + "zeebe", "sddh123865WUS)(1%!", audience)).Build(); + } + private async Task AwaitBrokerReadiness() { - var zeebeClient = (ZeebeClient) client; + var zeebeClient = withIdentity ? (ZeebeClient)CreateAuthenticatedZeebeClient() : (ZeebeClient)client; await zeebeClient.Connect(); var topologyErrorLogger = LoggerFactory.CreateLogger(); var ready = false; From 588296870bd4fa43bb7f34e23f12dd6539eadb4b Mon Sep 17 00:00:00 2001 From: Lennart Kleymann Date: Mon, 20 Nov 2023 10:54:01 +0100 Subject: [PATCH 08/21] test(OAuth2TokenProvider): added integration tests for OAuth2TokenProvider --- .../OAuthIntegrationTest.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 Client.IntegrationTests/OAuthIntegrationTest.cs diff --git a/Client.IntegrationTests/OAuthIntegrationTest.cs b/Client.IntegrationTests/OAuthIntegrationTest.cs new file mode 100644 index 00000000..3ae8e316 --- /dev/null +++ b/Client.IntegrationTests/OAuthIntegrationTest.cs @@ -0,0 +1,46 @@ +using System.Threading.Tasks; +using Grpc.Core; +using NUnit.Framework; + +namespace Client.IntegrationTests; + +[TestFixture] +public class OAuthIntegrationTest +{ + private readonly ZeebeIntegrationTestHelper testHelper = ZeebeIntegrationTestHelper.Latest(true); + + [OneTimeSetUp] + public async Task Setup() + { + await testHelper.SetupIntegrationTest(); + } + + [OneTimeTearDown] + public async Task Stop() + { + await testHelper.TearDownIntegrationTest(); + } + + [Test] + public async Task ShouldSendRequestAndNotFailingWithAuthenticatedClient() + { + var topology = await testHelper.CreateAuthenticatedZeebeClient().TopologyRequest().Send(); + var gatewayVersion = topology.GatewayVersion; + Assert.AreEqual(ZeebeIntegrationTestHelper.LatestVersion, gatewayVersion); + + var topologyBrokers = topology.Brokers; + Assert.AreEqual(1, topologyBrokers.Count); + + var topologyBroker = topologyBrokers[0]; + Assert.AreEqual(0, topologyBroker.NodeId); + } + + [Test] + public async Task ShouldFailWithUnauthenticatedClient() + { + Assert.ThrowsAsync(code: async () => + { + await testHelper.CreateZeebeClient().TopologyRequest().Send(); + }); + } +} \ No newline at end of file From 7a6c6ac9167dbe430f75a4fc257a98a0157d07b7 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 13:23:22 +0100 Subject: [PATCH 09/21] feat: introduce AccessTokenCache AccessTokenCache takes care of caching an AccessToken, and as well of storing to and reading from disk. --- .../Impl/Misc/AccessTokenCacheTest.cs | 75 +++++++++++++ Client/Impl/Misc/AccessTokenCache.cs | 104 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs create mode 100644 Client/Impl/Misc/AccessTokenCache.cs diff --git a/Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs b/Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs new file mode 100644 index 00000000..b505c7fb --- /dev/null +++ b/Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) 2021 camunda services GmbH (info@camunda.com) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Zeebe.Client.Impl.Misc; + +[TestFixture] +public class AccessTokenCacheTest +{ + private string tempPath; + + [SetUp] + public void Init() + { + tempPath = Path.GetTempPath() + ".zeebe/"; + Directory.CreateDirectory(tempPath); + } + + [TearDown] + public void CleanUp() + { + Directory.Delete(tempPath, true); + } + + [Test] + public async Task ShouldGetToken() + { + // given + var accessTokenCache = new AccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + () => + Task.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds()))); + + // when + var token = await accessTokenCache.Get("test"); + + // then + Assert.AreEqual("token", token); + } + + [Test] + public async Task ShouldCacheToken() + { + // given + int fetchCounter = 0; + var accessTokenCache = new AccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + () => + { + return Task.FromResult(new AccessToken("token-" + fetchCounter++, DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds())); + }); + + // when + await accessTokenCache.Get("test"); + var token = await accessTokenCache.Get("test"); + + // then + Assert.AreEqual("token-0", token); + Assert.AreEqual(1, fetchCounter); + } +} \ No newline at end of file diff --git a/Client/Impl/Misc/AccessTokenCache.cs b/Client/Impl/Misc/AccessTokenCache.cs new file mode 100644 index 00000000..4616dee1 --- /dev/null +++ b/Client/Impl/Misc/AccessTokenCache.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Zeebe.Client.Api.Builder; + +namespace Zeebe.Client.Impl.Misc; + +public class AccessTokenCache +{ + private static string ZeebeTokenFileName => "credentials"; + private Dictionary CachedCredentials { get; set; } + + // private static readonly string ZeebeRootPath = + // Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); + + private readonly ILogger logger; + private readonly AccessTokenResolverAsync accessTokenFetcherAsync; + + private readonly string tokenStoragePath; + private string TokenFileName => Path.Combine(tokenStoragePath, ZeebeTokenFileName); + + public AccessTokenCache(string path, AccessTokenResolverAsync fetcherAsync, ILogger logger = null) + { + var directoryInfo = Directory.CreateDirectory(path); + if (!directoryInfo.Exists) + { + throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); + } + + tokenStoragePath = path; + this.logger = logger; + accessTokenFetcherAsync = fetcherAsync; + CachedCredentials = new Dictionary(); + } + + public async Task Get(string audience) + { + // check in memory + if (CachedCredentials.TryGetValue(audience, out var currentAccessToken)) + { + logger?.LogTrace("Use in memory access token"); + return await GetValidToken(audience, currentAccessToken); + } + + // check if token file exists + var useCachedFileToken = File.Exists(TokenFileName); + if (useCachedFileToken) + { + 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)) + { + logger?.LogTrace("Found access token in credentials file"); + return await GetValidToken(audience, currentAccessToken); + } + } + + // fetch new token + var newAccessToken = await FetchNewAccessToken(audience); + return newAccessToken.Token; + } + + private async Task GetValidToken(string audience, AccessToken currentAccessToken) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var dueDate = currentAccessToken.DueDate; + if (now < dueDate) + { + // 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); + return newAccessToken.Token; + } + + private async Task FetchNewAccessToken(string audience) + { + var newAccessToken = await accessTokenFetcherAsync(); + CachedCredentials[audience] = newAccessToken; + WriteCredentials(); + return newAccessToken; + } + + private void WriteCredentials() + { + File.WriteAllText(TokenFileName, JsonConvert.SerializeObject(CachedCredentials)); + } + + /// + /// An asynchronous access token resolver, which is used to fill the cache, when + /// token can't be found. + /// + /// The new access token. + public delegate Task AccessTokenResolverAsync(); +} \ No newline at end of file From 5bc88ee7c0953847a1d20946b68fc8a744d044b7 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 13:31:08 +0100 Subject: [PATCH 10/21] refactor: introduce new IAccessTokenCache --- ...st.cs => PersistedAccessTokenCacheTest.cs} | 6 +-- Client/Impl/Misc/AccessToken.cs | 3 ++ Client/Impl/Misc/IAccessTokenCache.cs | 43 +++++++++++++++++++ ...nCache.cs => PersistedAccessTokenCache.cs} | 15 ++----- 4 files changed, 53 insertions(+), 14 deletions(-) rename Client.UnitTests/Impl/Misc/{AccessTokenCacheTest.cs => PersistedAccessTokenCacheTest.cs} (87%) create mode 100644 Client/Impl/Misc/IAccessTokenCache.cs rename Client/Impl/Misc/{AccessTokenCache.cs => PersistedAccessTokenCache.cs} (85%) diff --git a/Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs similarity index 87% rename from Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs rename to Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs index b505c7fb..255ae3b0 100644 --- a/Client.UnitTests/Impl/Misc/AccessTokenCacheTest.cs +++ b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs @@ -21,7 +21,7 @@ namespace Zeebe.Client.Impl.Misc; [TestFixture] -public class AccessTokenCacheTest +public class PersistedAccessTokenCacheTest { private string tempPath; @@ -42,7 +42,7 @@ public void CleanUp() public async Task ShouldGetToken() { // given - var accessTokenCache = new AccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + var accessTokenCache = new PersistedAccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), () => Task.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds()))); @@ -58,7 +58,7 @@ public async Task ShouldCacheToken() { // given int fetchCounter = 0; - var accessTokenCache = new AccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + var accessTokenCache = new PersistedAccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), () => { return Task.FromResult(new AccessToken("token-" + fetchCounter++, DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds())); diff --git a/Client/Impl/Misc/AccessToken.cs b/Client/Impl/Misc/AccessToken.cs index fbe2352f..8046ff15 100644 --- a/Client/Impl/Misc/AccessToken.cs +++ b/Client/Impl/Misc/AccessToken.cs @@ -3,6 +3,9 @@ namespace Zeebe.Client.Impl.Misc; +/// +/// AccessToken, which consist of an token and a dueDate (expiryDate). +/// public class AccessToken { public string Token { get; set; } diff --git a/Client/Impl/Misc/IAccessTokenCache.cs b/Client/Impl/Misc/IAccessTokenCache.cs new file mode 100644 index 00000000..0b3e2f1f --- /dev/null +++ b/Client/Impl/Misc/IAccessTokenCache.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) 2021 camunda services GmbH (info@camunda.com) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading.Tasks; + +namespace Zeebe.Client.Impl.Misc; + +/// +/// cache, which allows to cache tokens per audience. +/// +public interface IAccessTokenCache +{ + /// + /// A valid token, which is related to the given audience. + /// + /// + /// The token may be cached, or new resolved if there was no token corresponding + /// to the audience stored yet, or if the token has been expired. + /// The audience which corresponds to the token. + /// A valid token for the audience. + Task Get(string audience); + + /// + /// An asynchronous access token resolver, which is used to fill the cache, when + /// token can't be found. + /// + /// + /// Resolver should be given to the cache, on creation time. + /// The new access token. + public delegate Task AccessTokenResolverAsync(); +} \ No newline at end of file diff --git a/Client/Impl/Misc/AccessTokenCache.cs b/Client/Impl/Misc/PersistedAccessTokenCache.cs similarity index 85% rename from Client/Impl/Misc/AccessTokenCache.cs rename to Client/Impl/Misc/PersistedAccessTokenCache.cs index 4616dee1..add57200 100644 --- a/Client/Impl/Misc/AccessTokenCache.cs +++ b/Client/Impl/Misc/PersistedAccessTokenCache.cs @@ -9,7 +9,7 @@ namespace Zeebe.Client.Impl.Misc; -public class AccessTokenCache +public class PersistedAccessTokenCache : IAccessTokenCache { private static string ZeebeTokenFileName => "credentials"; private Dictionary CachedCredentials { get; set; } @@ -17,13 +17,13 @@ public class AccessTokenCache // private static readonly string ZeebeRootPath = // Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); - private readonly ILogger logger; - private readonly AccessTokenResolverAsync accessTokenFetcherAsync; + private readonly ILogger logger; + private readonly IAccessTokenCache.AccessTokenResolverAsync accessTokenFetcherAsync; private readonly string tokenStoragePath; private string TokenFileName => Path.Combine(tokenStoragePath, ZeebeTokenFileName); - public AccessTokenCache(string path, AccessTokenResolverAsync fetcherAsync, ILogger logger = null) + public PersistedAccessTokenCache(string path, IAccessTokenCache.AccessTokenResolverAsync fetcherAsync, ILogger logger = null) { var directoryInfo = Directory.CreateDirectory(path); if (!directoryInfo.Exists) @@ -94,11 +94,4 @@ private void WriteCredentials() { File.WriteAllText(TokenFileName, JsonConvert.SerializeObject(CachedCredentials)); } - - /// - /// An asynchronous access token resolver, which is used to fill the cache, when - /// token can't be found. - /// - /// The new access token. - public delegate Task AccessTokenResolverAsync(); } \ No newline at end of file From cb608bb745846d9a79eaba1bd9cc2abf0b8165ee Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 13:31:40 +0100 Subject: [PATCH 11/21] fix: downgrade to .net 7.0 Currently not able to compile, nor get dotnet 8.0 on fedora 37 --- global.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index 34fe4d43..37704f06 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.100", + "version": "7.0.0", "rollForward": "latestMajor", "allowPrerelease": true } -} \ No newline at end of file +} From b81184511f1a779c6d327abdd33dcceaa99cf9d1 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 13:52:48 +0100 Subject: [PATCH 12/21] test: add additional tests for cache --- .../Misc/PersistedAccessTokenCacheTest.cs | 145 +++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs index 255ae3b0..d27d8391 100644 --- a/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs +++ b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs @@ -59,10 +59,7 @@ public async Task ShouldCacheToken() // given int fetchCounter = 0; var accessTokenCache = new PersistedAccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), - () => - { - return Task.FromResult(new AccessToken("token-" + fetchCounter++, DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds())); - }); + () => Task.FromResult(new AccessToken("token-" + fetchCounter++, DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds()))); // when await accessTokenCache.Get("test"); @@ -72,4 +69,144 @@ public async Task ShouldCacheToken() Assert.AreEqual("token-0", token); Assert.AreEqual(1, fetchCounter); } + + [Test] + public async Task ShouldCacheTokenForDifferentAudience() + { + // given + int fetchCounter = 0; + var accessTokenCache = new PersistedAccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + () => Task.FromResult(new AccessToken("token-" + fetchCounter++, DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds()))); + + // when + var firstToken = await accessTokenCache.Get("first"); + var secondToken = await accessTokenCache.Get("second"); + + // then + Assert.AreEqual("token-0", firstToken); + Assert.AreEqual("token-1", secondToken); + Assert.AreEqual(2, fetchCounter); + } + + [Test] + public async Task ShouldResolveNewTokenAfterExpiry() + { + // given + int fetchCounter = 0; + var accessTokenCache = new PersistedAccessTokenCache(Path.Combine(tempPath, TestContext.CurrentContext.Test.Name), + () => Task.FromResult(new AccessToken("token-" + fetchCounter++, DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds()))); + + // 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 ShouldReflectTokenOnDiskAfterExpiry() + { + // given + var audience = "test"; + int 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()))); + var firstToken = await accessTokenCache.Get(audience); + + var credentials = await File.ReadAllTextAsync(Directory.GetFiles(path)[0]); + Assert.That(credentials, Does.Contain(firstToken)); + Assert.That(credentials, Does.Contain(audience)); + + // when + var secondToken = await accessTokenCache.Get(audience); + + // then + Assert.AreNotEqual(secondToken, firstToken); + Assert.AreEqual("token-1", secondToken); + Assert.AreEqual(2, fetchCounter); + + credentials = await File.ReadAllTextAsync(Directory.GetFiles(path)[0]); + Assert.That(credentials, Does.Contain(secondToken)); + Assert.That(credentials, Does.Contain(audience)); + } + + [Test] + public async Task ShouldPersistTokenToDisk() + { + // given + var audience = "test"; + int 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()))); + + // when + var token = await accessTokenCache.Get(audience); + + // then + var fileNames = Directory.GetFiles(path); + Assert.AreEqual(1, fileNames.Length); + var content = await File.ReadAllTextAsync(fileNames[0]); + Assert.That(content, Does.Contain(token)); + Assert.That(content, Does.Contain(audience)); + } + + + [Test] + public async Task ShouldPersistMultipleTokenToDisk() + { + // given + int 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()))); + + // when + var firstToken = await accessTokenCache.Get("first"); + var secondToken = await accessTokenCache.Get("second"); + + // then + Assert.AreEqual("token-0", firstToken); + Assert.AreEqual("token-1", secondToken); + Assert.AreEqual(2, fetchCounter); + + var fileNames = Directory.GetFiles(path); + Assert.AreEqual(1, fileNames.Length); + var content = await File.ReadAllTextAsync(fileNames[0]); + Assert.That(content, Does.Contain(firstToken)); + Assert.That(content, Does.Contain("first")); + + + Assert.That(content, Does.Contain(secondToken)); + Assert.That(content, Does.Contain("second")); + } + + [Test] + public async Task ShouldFetchNewTokenWhenPersistTokenGotLost() + { + // given + var audience = "test"; + int 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()))); + await accessTokenCache.Get(audience); + File.Delete(Directory.GetFiles(path)[0]); + + // when + var token = await accessTokenCache.Get(audience); + + // then + Assert.AreEqual("token-1", token); + Assert.AreEqual(2, fetchCounter); + var fileNames = Directory.GetFiles(path); + Assert.AreEqual(1, fileNames.Length); + var content = await File.ReadAllTextAsync(fileNames[0]); + Assert.That(content, Does.Contain(token)); + Assert.That(content, Does.Contain(audience)); + } } \ No newline at end of file From 26316ae8d9b063e250253f929a63c2f465030efa Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 14:11:39 +0100 Subject: [PATCH 13/21] refactor: use new Cache in token provider Instead of using the base class, use the new PersistedAccessTokenCache which gives us all necessary functionality and simplifies the TokenProvider a lot. Additionally add a new method to set the path were the credentials are stored --- .../CamundaCloudTokenProviderTest.cs | 7 +- .../ICamundaCloudTokenProviderBuilder.cs | 9 +++ .../Impl/Builder/CamundaCloudTokenProvider.cs | 72 +++++++++---------- .../CamundaCloudTokenProviderBuilder.cs | 13 ++++ 4 files changed, 61 insertions(+), 40 deletions(-) diff --git a/Client.UnitTests/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/CamundaCloudTokenProviderTest.cs index 32e1abc3..7bc541b3 100644 --- a/Client.UnitTests/CamundaCloudTokenProviderTest.cs +++ b/Client.UnitTests/CamundaCloudTokenProviderTest.cs @@ -34,17 +34,17 @@ public void Init() _clientId = "ID"; _clientSecret = "SECRET"; _audience = "AUDIENCE"; + TokenStoragePath = Path.GetTempPath() + ".zeebe/"; TokenProvider = new CamundaCloudTokenProviderBuilder() .UseAuthServer(_requestUri) .UseClientId(_clientId) .UseClientSecret(_clientSecret) .UseAudience(_audience) + .UsePath(TokenStoragePath) .Build(); MessageHandlerStub = new HttpMessageHandlerStub(); TokenProvider.SetHttpMessageHandler(MessageHandlerStub); - TokenStoragePath = Path.GetTempPath() + ".zeebe/"; - TokenProvider.TokenStoragePath = TokenStoragePath; ExpiresIn = 3600; Token = "REQUESTED_TOKEN"; } @@ -142,9 +142,9 @@ public async Task ShouldStoreMultipleCredentials() .UseClientId(_clientId = "OTHERID") .UseClientSecret(_clientSecret = "OTHERSECRET") .UseAudience(_audience = "OTHER_AUDIENCE") + .UsePath(TokenStoragePath) .Build(); otherProvider.SetHttpMessageHandler(MessageHandlerStub); - otherProvider.TokenStoragePath = TokenStoragePath; Token = "OTHER_TOKEN"; // when @@ -254,7 +254,6 @@ public async Task ShouldNotUseCachedFileForOtherAudience() .UseAudience(_audience = "OTHER_AUDIENCE") .Build(); otherProvider.SetHttpMessageHandler(MessageHandlerStub); - otherProvider.TokenStoragePath = TokenStoragePath; Token = "OTHER_TOKEN"; // when diff --git a/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs b/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs index 968d30e4..d623b237 100644 --- a/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs +++ b/Client/Api/Builder/ICamundaCloudTokenProviderBuilder.cs @@ -58,6 +58,15 @@ public interface ICamundaCloudTokenProviderBuilderStep4 public interface ICamundaCloudTokenProviderBuilderFinalStep { + + /// + /// Use given path to store credentials on disk. + /// + /// Per default credentials are stored in the home directory. + /// The path were to store the credentials. + /// The final step in building a CamundaCloudTokenProvider. + ICamundaCloudTokenProviderBuilderFinalStep UsePath(string path); + /// /// Builds the CamundaCloudTokenProvider, which can be used by the ZeebeClient to /// communicate with the Camunda Cloud. diff --git a/Client/Impl/Builder/CamundaCloudTokenProvider.cs b/Client/Impl/Builder/CamundaCloudTokenProvider.cs index 025c74bb..51b7e9df 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -1,16 +1,20 @@ using System; -using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Zeebe.Client.Api.Builder; using Zeebe.Client.Impl.Misc; namespace Zeebe.Client.Impl.Builder { - public class CamundaCloudTokenProvider : BaseTokenProvider, IDisposable + public class CamundaCloudTokenProvider : IAccessTokenSupplier, IDisposable { + private static readonly string ZeebeRootPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); + private const string JsonContent = "{{\"client_id\":\"{0}\",\"client_secret\":\"{1}\",\"audience\":\"{2}\",\"grant_type\":\"client_credentials\"}}"; @@ -21,23 +25,23 @@ public class CamundaCloudTokenProvider : BaseTokenProvider, IDisposable private readonly string audience; private HttpClient httpClient; private HttpMessageHandler httpMessageHandler; + private readonly PersistedAccessTokenCache persistedAccessTokenCache; internal CamundaCloudTokenProvider( string authServer, string clientId, string clientSecret, string audience, - ILogger logger = null) : base("credentials", audience) + string path = null, + ILogger logger = null) { + persistedAccessTokenCache = new PersistedAccessTokenCache(path ?? ZeebeRootPath, FetchAccessToken); this.logger = logger; this.authServer = authServer; this.clientId = clientId; this.clientSecret = clientSecret; this.audience = audience; - - // default client handler httpClient = new HttpClient(new HttpClientHandler(), disposeHandler: false); - CachedCredentials = new Dictionary(); } public static CamundaCloudTokenProviderBuilder Builder() @@ -51,44 +55,34 @@ internal void SetHttpMessageHandler(HttpMessageHandler handler) httpClient = new HttpClient(handler); } - // Requesting the token is similar to this: - // curl --request POST \ - // --url https://login.cloud.[ultrawombat.com | camunda.io]/oauth/token \ - // --header 'content-type: application/json' \ - // --data '{"client_id":"${clientId}","client_secret":"${clientSecret}","audience":"${audience}","grant_type":"client_credentials"}' - - // Code expects the following result: - // - // { - // "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", - // "token_type":"bearer", - // "expires_in":3600, - // "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", - // "scope":"create" - // } - // - // Defined here https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ - - protected override async Task RequestAccessTokenAsync() + private async Task FetchAccessToken() { - var directoryInfo = Directory.CreateDirectory(TokenStoragePath); - if (!directoryInfo.Exists) - { - throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); - } - var json = string.Format(JsonContent, clientId, clientSecret, audience); + // Requesting the token is similar to this: + // curl --request POST \ + // --url https://login.cloud.[ultrawombat.com | camunda.io]/oauth/token \ + // --header 'content-type: application/json' \ + // --data '{"client_id":"${clientId}","client_secret":"${clientSecret}","audience":"${audience}","grant_type":"client_credentials"}' + + // Code expects the following result: + // + // { + // "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + // "token_type":"bearer", + // "expires_in":3600, + // "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", + // "scope":"create" + // } + // + // Defined here https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ using var content = new StringContent(json, Encoding.UTF8, "application/json"); var httpResponseMessage = await httpClient.PostAsync(authServer, content); var result = await httpResponseMessage.Content.ReadAsStringAsync(); var token = AccessToken.FromJson(result); - logger?.LogDebug("Received access token for {audience}", audience); - CachedCredentials[audience] = token; - WriteCredentials(); - - return token.Token; + logger?.LogDebug("Received access token for {Audience}", audience); + return token; } public void Dispose() @@ -96,5 +90,11 @@ public void Dispose() httpClient.Dispose(); httpMessageHandler.Dispose(); } + + public async Task GetAccessTokenForRequestAsync(string authUri = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + return await persistedAccessTokenCache.Get(audience); + } } } \ No newline at end of file diff --git a/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs b/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs index 5a56c4cc..e0338de4 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs @@ -18,6 +18,7 @@ public class CamundaCloudTokenProviderBuilder : private string authServer = "https://login.cloud.camunda.io/oauth/token"; private string clientId; private string clientSecret; + private string path; /// public ICamundaCloudTokenProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) @@ -74,6 +75,17 @@ public ICamundaCloudTokenProviderBuilderFinalStep UseAudience(string audience) return this; } + public ICamundaCloudTokenProviderBuilderFinalStep UsePath(string path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + this.path = path; + return this; + } + /// public CamundaCloudTokenProvider Build() { @@ -82,6 +94,7 @@ public CamundaCloudTokenProvider Build() clientId, clientSecret, audience, + path, loggerFactory?.CreateLogger()); } } From b27a3a100c3bfb23e11e812a564a86ce2b44fa08 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 14:14:26 +0100 Subject: [PATCH 14/21] test: remove duplicate tests Most of the logic is already tested in Cache tests --- .../CamundaCloudTokenProviderTest.cs | 188 ------------------ 1 file changed, 188 deletions(-) diff --git a/Client.UnitTests/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/CamundaCloudTokenProviderTest.cs index 7bc541b3..ac9bdfc4 100644 --- a/Client.UnitTests/CamundaCloudTokenProviderTest.cs +++ b/Client.UnitTests/CamundaCloudTokenProviderTest.cs @@ -114,194 +114,6 @@ public async Task ShouldRequestCredentials() Assert.AreEqual(1, MessageHandlerStub.RequestCount); } - [Test] - public async Task ShouldStoreCredentials() - { - // given - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - var files = Directory.GetFiles(TokenStoragePath); - Assert.AreEqual(1, files.Length); - var tokenFile = files[0]; - var content = File.ReadAllText(tokenFile); - var credentials = JsonConvert.DeserializeObject>(content); - Assert.AreEqual(credentials["AUDIENCE"].Token, token); - } - - [Test] - public async Task ShouldStoreMultipleCredentials() - { - // given - await TokenProvider.GetAccessTokenForRequestAsync(); - var otherProvider = new CamundaCloudTokenProviderBuilder() - .UseAuthServer(_requestUri) - .UseClientId(_clientId = "OTHERID") - .UseClientSecret(_clientSecret = "OTHERSECRET") - .UseAudience(_audience = "OTHER_AUDIENCE") - .UsePath(TokenStoragePath) - .Build(); - otherProvider.SetHttpMessageHandler(MessageHandlerStub); - Token = "OTHER_TOKEN"; - - // when - var token = await otherProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("OTHER_TOKEN", token); - var files = Directory.GetFiles(TokenStoragePath); - Assert.AreEqual(1, files.Length); - var tokenFile = files[0]; - var content = File.ReadAllText(tokenFile); - var credentials = JsonConvert.DeserializeObject>(content); - - Assert.AreEqual(credentials.Count, 2); - Assert.AreEqual(token, credentials["OTHER_AUDIENCE"].Token); - Assert.AreEqual("REQUESTED_TOKEN", credentials["AUDIENCE"].Token); - } - - [Test] - public async Task ShouldGetTokenFromInMemory() - { - // given - await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - Assert.AreEqual(1, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldExpireInOneSecond() - { - // given - ExpiresIn = 1; - var firstToken = await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - - // when - Token = "NEW_TOKEN"; - var secondToken = await TokenProvider.GetAccessTokenForRequestAsync(); - Thread.Sleep(1_000); - var thirdToken = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", firstToken); - Assert.AreEqual(secondToken, firstToken); - Assert.AreEqual("NEW_TOKEN", thirdToken); - Assert.AreEqual(2, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldRequestNewTokenWhenExpired() - { - // given - ExpiresIn = 0; - var firstToken = await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - - // when - Token = "SECOND_TOKEN"; - var secondToken = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", firstToken); - Assert.AreNotEqual(secondToken, firstToken); - Assert.AreEqual("SECOND_TOKEN", secondToken); - Assert.AreEqual(2, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldUseCachedFile() - { - // given - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - // re-init the TokenProvider - Init(); - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("STORED_TOKEN", token); - Assert.AreEqual(0, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldNotUseCachedFileForOtherAudience() - { - // given - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - var otherProvider = new CamundaCloudTokenProviderBuilder() - .UseAuthServer(_requestUri) - .UseClientId(_clientId = "OTHERID") - .UseClientSecret(_clientSecret = "OTHERSECRET") - .UseAudience(_audience = "OTHER_AUDIENCE") - .Build(); - otherProvider.SetHttpMessageHandler(MessageHandlerStub); - Token = "OTHER_TOKEN"; - - // when - var token = await otherProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("OTHER_TOKEN", token); - } - - [Test] - public async Task ShouldRequestWhenCachedFileExpired() - { - // given - ExpiresIn = 0; - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - // re-init the TokenProvider - Init(); - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - Assert.AreEqual(1, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldUseCachedFileAndAfterwardsInMemory() - { - // given - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - // re-init the TokenProvider - Init(); - - // when - await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("STORED_TOKEN", token); - Assert.AreEqual(0, MessageHandlerStub.RequestCount); - } - [Test] public async Task ShouldNotThrowObjectDisposedExceptionWhenTokenExpires() { From 906c4d8888db4810c3156f678fa4c08473150f9e Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 14:15:19 +0100 Subject: [PATCH 15/21] refactor: move to right namespace --- .../{ => Impl/Builder}/CamundaCloudTokenProviderTest.cs | 6 +----- .../{ => Impl/Builder}/OAuth2TokenProviderTest.cs | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) rename Client.UnitTests/{ => Impl/Builder}/CamundaCloudTokenProviderTest.cs (96%) rename Client.UnitTests/{ => Impl/Builder}/OAuth2TokenProviderTest.cs (99%) diff --git a/Client.UnitTests/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs similarity index 96% rename from Client.UnitTests/CamundaCloudTokenProviderTest.cs rename to Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs index ac9bdfc4..5036cb67 100644 --- a/Client.UnitTests/CamundaCloudTokenProviderTest.cs +++ b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs @@ -1,17 +1,13 @@ using System; -using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; -using Zeebe.Client.Impl.Builder; -using Zeebe.Client.Impl.Misc; -namespace Zeebe.Client +namespace Zeebe.Client.Impl.Builder { [TestFixture] public class CamundaCloudTokenProviderTest diff --git a/Client.UnitTests/OAuth2TokenProviderTest.cs b/Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs similarity index 99% rename from Client.UnitTests/OAuth2TokenProviderTest.cs rename to Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs index 13eae313..2bbd0e0f 100644 --- a/Client.UnitTests/OAuth2TokenProviderTest.cs +++ b/Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs @@ -6,12 +6,10 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using NUnit.Framework; -using Zeebe.Client.Impl.Builder; using Zeebe.Client.Impl.Misc; -namespace Zeebe.Client +namespace Zeebe.Client.Impl.Builder { [TestFixture] public class OAuth2TokenProviderTest From e7474ca3efcf3740667a0a59420a5a80820b917d Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 14:33:24 +0100 Subject: [PATCH 16/21] refactor: migrate to token provider to form-url-encoded This should allow to use the CamundaCloudTokenProvider for SaaS but also with keycloak in SM --- .../Builder/CamundaCloudTokenProviderTest.cs | 9 +++--- .../Impl/Builder/CamundaCloudTokenProvider.cs | 30 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs index 5036cb67..0f743c13 100644 --- a/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs +++ b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using System.Web; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -63,10 +64,10 @@ protected override async Task SendAsync(HttpRequestMessage CheckDisposed(); Assert.AreEqual(request.RequestUri, _requestUri); var content = await request.Content.ReadAsStringAsync(); - var jsonObject = JObject.Parse(content); - Assert.AreEqual((string)jsonObject["client_id"], _clientId); - Assert.AreEqual((string)jsonObject["client_secret"], _clientSecret); - Assert.AreEqual((string)jsonObject["audience"], _audience); + var queryString = HttpUtility.ParseQueryString(content); + Assert.AreEqual((string)queryString["client_id"], _clientId); + Assert.AreEqual((string)queryString["client_secret"], _clientSecret); + Assert.AreEqual((string)queryString["audience"], _audience); RequestCount++; var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) diff --git a/Client/Impl/Builder/CamundaCloudTokenProvider.cs b/Client/Impl/Builder/CamundaCloudTokenProvider.cs index 51b7e9df..305396ee 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -1,7 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -15,9 +15,6 @@ public class CamundaCloudTokenProvider : IAccessTokenSupplier, IDisposable private static readonly string ZeebeRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); - private const string JsonContent = - "{{\"client_id\":\"{0}\",\"client_secret\":\"{1}\",\"audience\":\"{2}\",\"grant_type\":\"client_credentials\"}}"; - private readonly ILogger logger; private readonly string authServer; private readonly string clientId; @@ -57,14 +54,20 @@ internal void SetHttpMessageHandler(HttpMessageHandler handler) private async Task FetchAccessToken() { - var json = string.Format(JsonContent, clientId, clientSecret, audience); - // Requesting the token is similar to this: + // curl -X POST https://login.cloud.ultrawombat.com/oauth/token \ + // -H "Content-Type: application/x-www-form-urlencoded" \ + // -d "client_id=213131&client_secret=12-23~oU.321&audience=zeebe.ultrawombat.com&grant_type=client_credentials" + // + // alternative is json // curl --request POST \ // --url https://login.cloud.[ultrawombat.com | camunda.io]/oauth/token \ // --header 'content-type: application/json' \ // --data '{"client_id":"${clientId}","client_secret":"${clientSecret}","audience":"${audience}","grant_type":"client_credentials"}' + var formContent = BuildRequestAccessTokenContent(); + var httpResponseMessage = await httpClient.PostAsync(authServer, formContent); + // Code expects the following result: // // { @@ -76,15 +79,24 @@ private async Task FetchAccessToken() // } // // Defined here https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - var httpResponseMessage = await httpClient.PostAsync(authServer, content); - var result = await httpResponseMessage.Content.ReadAsStringAsync(); var token = AccessToken.FromJson(result); logger?.LogDebug("Received access token for {Audience}", audience); return token; } + private FormUrlEncodedContent BuildRequestAccessTokenContent() + { + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("client_secret", clientSecret), + new KeyValuePair("audience", audience), + new KeyValuePair("grant_type", "client_credentials") + }); + return formContent; + } + public void Dispose() { httpClient.Dispose(); From 78ae3e2b42789960dad3ce40ad4bd26c23534024 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 14:52:58 +0100 Subject: [PATCH 17/21] refactor: remove unused classes SaaS and SM can be handled with old CamundaCloudTokenProvider --- .../Impl/Builder/OAuth2TokenProviderTest.cs | 336 ------------------ .../Builder/IOAuth2TokenProviderBuilder.cs | 63 ---- Client/Impl/Builder/OAuth2TokenProvider.cs | 89 ----- .../Builder/OAuth2TokenProviderBuilder.cs | 64 ---- 4 files changed, 552 deletions(-) delete mode 100644 Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs delete mode 100644 Client/Api/Builder/IOAuth2TokenProviderBuilder.cs delete mode 100644 Client/Impl/Builder/OAuth2TokenProvider.cs delete mode 100644 Client/Impl/Builder/OAuth2TokenProviderBuilder.cs diff --git a/Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs b/Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs deleted file mode 100644 index 2bbd0e0f..00000000 --- a/Client.UnitTests/Impl/Builder/OAuth2TokenProviderTest.cs +++ /dev/null @@ -1,336 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Newtonsoft.Json; -using NUnit.Framework; -using Zeebe.Client.Impl.Misc; - -namespace Zeebe.Client.Impl.Builder -{ - [TestFixture] - public class OAuth2TokenProviderTest - { - private HttpMessageHandlerStub MessageHandlerStub { get; set; } - private OAuth2TokenProvider TokenProvider { get; set; } - private string TokenStoragePath { get; set; } - private static long ExpiresIn { get; set; } - private static string Token { get; set; } - - private static string _requestUri; - private static string _clientId; - private static string _clientSecret; - private static string _audience; - - [SetUp] - public void Init() - { - _requestUri = "https://local.de"; - _clientId = "ID"; - _clientSecret = "SECRET"; - _audience = "AUDIENCE"; - TokenProvider = new OAuth2TokenProviderBuilder() - .UseAuthServer(_requestUri) - .UseClientId(_clientId) - .UseClientSecret(_clientSecret) - .UseAudience(_audience) - .Build(); - - MessageHandlerStub = new HttpMessageHandlerStub(); - TokenProvider.SetHttpMessageHandler(MessageHandlerStub); - TokenStoragePath = Path.GetTempPath() + ".zeebe/"; - TokenProvider.TokenStoragePath = TokenStoragePath; - ExpiresIn = 3600; - Token = "REQUESTED_TOKEN"; - } - - [TearDown] - public void CleanUp() - { - Directory.Delete(TokenStoragePath, true); - TokenProvider.Dispose(); - } - - private class HttpMessageHandlerStub : HttpMessageHandler - { - public int RequestCount { get; set; } - private bool _disposed = false; - - protected override async Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) - { - CheckDisposed(); - Assert.AreEqual(request.RequestUri, _requestUri); - var content = await request.Content.ReadAsStringAsync(cancellationToken); - - var formFields = GetFormFields(content); - Assert.AreEqual(formFields["client_id"], _clientId); - Assert.AreEqual(formFields["client_secret"], _clientSecret); - Assert.AreEqual(formFields["audience"], _audience); - - RequestCount++; - var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(@"{ - ""access_token"":""" + Token + @""", - ""token_type"":""bearer"", - ""expires_in"": " + ExpiresIn + @", - ""refresh_token"":""IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk"", - ""scope"":""create""}"), - }; - - return responseMessage; - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - _disposed = true; - } - - private void CheckDisposed() - { - if (_disposed) - { - throw new ObjectDisposedException("HttpMessageHandlerStub"); - } - } - } - - [Test] - public async Task ShouldRequestCredentials() - { - // given - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - Assert.AreEqual(1, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldStoreCredentials() - { - // given - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - var files = Directory.GetFiles(TokenStoragePath); - Assert.AreEqual(1, files.Length); - var tokenFile = files[0]; - var content = File.ReadAllText(tokenFile); - var credentials = JsonConvert.DeserializeObject>(content); - Assert.AreEqual(credentials["AUDIENCE"].Token, token); - } - - [Test] - public async Task ShouldStoreMultipleCredentials() - { - // given - await TokenProvider.GetAccessTokenForRequestAsync(); - var otherProvider = new OAuth2TokenProviderBuilder() - .UseAuthServer(_requestUri) - .UseClientId(_clientId = "OTHERID") - .UseClientSecret(_clientSecret = "OTHERSECRET") - .UseAudience(_audience = "OTHER_AUDIENCE") - .Build(); - otherProvider.SetHttpMessageHandler(MessageHandlerStub); - otherProvider.TokenStoragePath = TokenStoragePath; - Token = "OTHER_TOKEN"; - - // when - var token = await otherProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("OTHER_TOKEN", token); - var files = Directory.GetFiles(TokenStoragePath); - Assert.AreEqual(1, files.Length); - var tokenFile = files[0]; - var content = File.ReadAllText(tokenFile); - var credentials = JsonConvert.DeserializeObject>(content); - - Assert.AreEqual(credentials.Count, 2); - Assert.AreEqual(token, credentials["OTHER_AUDIENCE"].Token); - Assert.AreEqual("REQUESTED_TOKEN", credentials["AUDIENCE"].Token); - } - - [Test] - public async Task ShouldGetTokenFromInMemory() - { - // given - await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - Assert.AreEqual(1, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldExpireInOneSecond() - { - // given - ExpiresIn = 1; - var firstToken = await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - - // when - Token = "NEW_TOKEN"; - var secondToken = await TokenProvider.GetAccessTokenForRequestAsync(); - Thread.Sleep(1_000); - var thirdToken = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", firstToken); - Assert.AreEqual(secondToken, firstToken); - Assert.AreEqual("NEW_TOKEN", thirdToken); - Assert.AreEqual(2, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldRequestNewTokenWhenExpired() - { - // given - ExpiresIn = 0; - var firstToken = await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - - // when - Token = "SECOND_TOKEN"; - var secondToken = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", firstToken); - Assert.AreNotEqual(secondToken, firstToken); - Assert.AreEqual("SECOND_TOKEN", secondToken); - Assert.AreEqual(2, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldUseCachedFile() - { - // given - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - // re-init the TokenProvider - Init(); - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("STORED_TOKEN", token); - Assert.AreEqual(0, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldNotUseCachedFileForOtherAudience() - { - // given - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - var otherProvider = new OAuth2TokenProviderBuilder() - .UseAuthServer(_requestUri) - .UseClientId(_clientId = "OTHERID") - .UseClientSecret(_clientSecret = "OTHERSECRET") - .UseAudience(_audience = "OTHER_AUDIENCE") - .Build(); - otherProvider.SetHttpMessageHandler(MessageHandlerStub); - otherProvider.TokenStoragePath = TokenStoragePath; - Token = "OTHER_TOKEN"; - - // when - var token = await otherProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("OTHER_TOKEN", token); - } - - [Test] - public async Task ShouldRequestWhenCachedFileExpired() - { - // given - ExpiresIn = 0; - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - // re-init the TokenProvider - Init(); - - // when - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("REQUESTED_TOKEN", token); - Assert.AreEqual(1, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldUseCachedFileAndAfterwardsInMemory() - { - // given - Token = "STORED_TOKEN"; - await TokenProvider.GetAccessTokenForRequestAsync(); - // re-init the TokenProvider - Init(); - - // when - await TokenProvider.GetAccessTokenForRequestAsync(); - var files = Directory.GetFiles(TokenStoragePath); - var tokenFile = files[0]; - File.WriteAllText(tokenFile, "FILE_TOKEN"); - var token = await TokenProvider.GetAccessTokenForRequestAsync(); - - // then - Assert.AreEqual("STORED_TOKEN", token); - Assert.AreEqual(0, MessageHandlerStub.RequestCount); - } - - [Test] - public async Task ShouldNotThrowObjectDisposedExceptionWhenTokenExpires() - { - // given - ExpiresIn = 0; - await TokenProvider.GetAccessTokenForRequestAsync(); - - // when - Assert.DoesNotThrowAsync(async () => await TokenProvider.GetAccessTokenForRequestAsync()); - - // then - Assert.AreEqual(2, MessageHandlerStub.RequestCount); - } - - private static Dictionary GetFormFields(string content) - { - var formFields = new Dictionary(); - var keyValuePairs = content.Split('&'); - foreach (var keyValuePair in keyValuePairs) - { - var pair = keyValuePair.Split('='); - if (pair.Length == 2) - { - var key = Uri.UnescapeDataString(pair[0]); - var value = Uri.UnescapeDataString(pair[1]); - formFields.Add(key, value); - } - } - return formFields; - } - } -} \ No newline at end of file diff --git a/Client/Api/Builder/IOAuth2TokenProviderBuilder.cs b/Client/Api/Builder/IOAuth2TokenProviderBuilder.cs deleted file mode 100644 index 24c390d1..00000000 --- a/Client/Api/Builder/IOAuth2TokenProviderBuilder.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.Extensions.Logging; -using Zeebe.Client.Impl.Builder; - -namespace Zeebe.Client.Api.Builder; - -public interface IOAuth2TokenProviderBuilder -{ - /// - /// Defines the logger factory which should be used by the token provider - /// to log messages. - /// *This is optional and no messages are logged if this method is not called.* - /// - /// the factory to create an ILogger - /// the fluent IOAuth2TokenProviderBuilder - IOAuth2TokenProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory); - - /// - /// Defines the authorization server, from which the access token should be requested. - /// - /// an url, which points to the authorization server - /// the next step in building a OAuth2TokenProvider - IOAuth2TokenProviderBuilderStep2 UseAuthServer(string url); -} - -public interface IOAuth2TokenProviderBuilderStep2 -{ - /// - /// Defines the client id, which should be used to create the access token. - /// - /// the client id, which is supplied by OAuth2 - /// the next step in building a OAuth2TokenProvider - IOAuth2TokenProviderBuilderStep3 UseClientId(string clientId); -} - -public interface IOAuth2TokenProviderBuilderStep3 -{ - /// - /// Defines the client secret, which should be used to create the access token. - /// - /// the client secret, which is supplied by OAuth2 - /// the next step in building a OAuth2TokenProvider - IOAuth2TokenProviderBuilderStep4 UseClientSecret(string clientSecret); -} - -public interface IOAuth2TokenProviderBuilderStep4 -{ - /// - /// Defines the audience for which the token provider should create tokens. - /// - /// the audience, which is normally a domain name - /// the next step in building a OAuth2TokenProvider - IOAuth2TokenProviderBuilderFinalStep UseAudience(string audience); -} - -public interface IOAuth2TokenProviderBuilderFinalStep -{ - /// - /// Builds the OAuth2TokenProvider, which can be used by the ZeebeClient to - /// communicate with a Self-Hosted Zeebe gateway, which uses identity. - /// - /// the OAuth2TokenProvider - OAuth2TokenProvider Build(); -} \ No newline at end of file diff --git a/Client/Impl/Builder/OAuth2TokenProvider.cs b/Client/Impl/Builder/OAuth2TokenProvider.cs deleted file mode 100644 index 9290fdc8..00000000 --- a/Client/Impl/Builder/OAuth2TokenProvider.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Zeebe.Client.Impl.Misc; - -namespace Zeebe.Client.Impl.Builder -{ - public class OAuth2TokenProvider : BaseTokenProvider, IDisposable - { - private readonly ILogger logger; - private readonly string authServer; - private readonly string clientId; - private readonly string clientSecret; - private readonly string audience; - private HttpClient httpClient; - private HttpMessageHandler httpMessageHandler; - - public OAuth2TokenProvider( - string authServer, - string clientId, - string clientSecret, - string audience, - ILogger logger = null) : base("oauth2_credentials", audience) - { - this.logger = logger; - this.authServer = authServer; - this.clientId = clientId; - this.clientSecret = clientSecret; - this.audience = audience; - - // default client handler - httpClient = new HttpClient(new HttpClientHandler(), disposeHandler: false); - CachedCredentials = new Dictionary(); - } - - public static CamundaCloudTokenProviderBuilder Builder() - { - return new CamundaCloudTokenProviderBuilder(); - } - - internal void SetHttpMessageHandler(HttpMessageHandler handler) - { - httpMessageHandler = handler; - httpClient = new HttpClient(handler); - } - - protected override async Task RequestAccessTokenAsync() - { - var directoryInfo = Directory.CreateDirectory(TokenStoragePath); - if (!directoryInfo.Exists) - { - throw new IOException("Expected to create '~/.zeebe/' directory, but failed to do so."); - } - - var formContent = BuildRequestAccessTokenContent(); - - var httpResponseMessage = await httpClient.PostAsync(authServer, formContent); - - var result = await httpResponseMessage.Content.ReadAsStringAsync(); - var token = AccessToken.FromJson(result); - logger?.LogDebug("Received access token for {audience}", audience); - CachedCredentials[audience] = token; - WriteCredentials(); - - return token.Token; - } - - private FormUrlEncodedContent BuildRequestAccessTokenContent() - { - var formContent = new FormUrlEncodedContent(new[] - { - new KeyValuePair("client_id", clientId), - new KeyValuePair("client_secret", clientSecret), - new KeyValuePair("audience", audience), - new KeyValuePair("grant_type", "client_credentials") - }); - return formContent; - } - - public void Dispose() - { - httpClient.Dispose(); - httpMessageHandler.Dispose(); - } - } -} \ No newline at end of file diff --git a/Client/Impl/Builder/OAuth2TokenProviderBuilder.cs b/Client/Impl/Builder/OAuth2TokenProviderBuilder.cs deleted file mode 100644 index 288e83f6..00000000 --- a/Client/Impl/Builder/OAuth2TokenProviderBuilder.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; -using Zeebe.Client.Api.Builder; - -namespace Zeebe.Client.Impl.Builder; - -public class OAuth2TokenProviderBuilder : IOAuth2TokenProviderBuilder, - IOAuth2TokenProviderBuilderStep2, - IOAuth2TokenProviderBuilderStep3, - IOAuth2TokenProviderBuilderStep4, - IOAuth2TokenProviderBuilderFinalStep -{ - private ILoggerFactory loggerFactory; - private string audience; - private string authServer = "https://login.cloud.camunda.io/oauth/token"; - private string clientId; - private string clientSecret; - - /// - public IOAuth2TokenProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) - { - this.loggerFactory = loggerFactory; - return this; - } - - /// - public IOAuth2TokenProviderBuilderStep2 UseAuthServer(string url) - { - authServer = url ?? throw new ArgumentNullException(nameof(url)); - return this; - } - - /// - public IOAuth2TokenProviderBuilderStep3 UseClientId(string clientId) - { - this.clientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); - return this; - } - - /// - public IOAuth2TokenProviderBuilderStep4 UseClientSecret(string clientSecret) - { - this.clientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); - return this; - } - - /// - public IOAuth2TokenProviderBuilderFinalStep UseAudience(string audience) - { - this.audience = audience ?? throw new ArgumentNullException(nameof(audience)); - return this; - } - - /// - public OAuth2TokenProvider Build() - { - return new OAuth2TokenProvider( - authServer, - clientId, - clientSecret, - audience, - loggerFactory?.CreateLogger()); - } -} \ No newline at end of file From 8620d5cc214783f2e71dc2995889a08ba9d0d300 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 14:54:19 +0100 Subject: [PATCH 18/21] test: adjust IT to use old token provider Use CamundaCloudTokenProvider with OAuth SM test Refactor a bit the ZeebeIntegrationTestHelper, remove booleans and make it more explicit --- .../OAuthIntegrationTest.cs | 8 ++-- .../ZeebeIntegrationTestHelper.cs | 41 ++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/Client.IntegrationTests/OAuthIntegrationTest.cs b/Client.IntegrationTests/OAuthIntegrationTest.cs index 3ae8e316..20546b04 100644 --- a/Client.IntegrationTests/OAuthIntegrationTest.cs +++ b/Client.IntegrationTests/OAuthIntegrationTest.cs @@ -7,7 +7,7 @@ namespace Client.IntegrationTests; [TestFixture] public class OAuthIntegrationTest { - private readonly ZeebeIntegrationTestHelper testHelper = ZeebeIntegrationTestHelper.Latest(true); + private readonly ZeebeIntegrationTestHelper testHelper = ZeebeIntegrationTestHelper.Latest().WithIdentity(); [OneTimeSetUp] public async Task Setup() @@ -24,7 +24,8 @@ public async Task Stop() [Test] public async Task ShouldSendRequestAndNotFailingWithAuthenticatedClient() { - var topology = await testHelper.CreateAuthenticatedZeebeClient().TopologyRequest().Send(); + var authenticatedZeebeClient = testHelper.CreateAuthenticatedZeebeClient(); + var topology = await authenticatedZeebeClient.TopologyRequest().Send(); var gatewayVersion = topology.GatewayVersion; Assert.AreEqual(ZeebeIntegrationTestHelper.LatestVersion, gatewayVersion); @@ -36,11 +37,12 @@ public async Task ShouldSendRequestAndNotFailingWithAuthenticatedClient() } [Test] - public async Task ShouldFailWithUnauthenticatedClient() + public Task ShouldFailWithUnauthenticatedClient() { Assert.ThrowsAsync(code: async () => { await testHelper.CreateZeebeClient().TopologyRequest().Send(); }); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs index 34cd6922..1d89e795 100644 --- a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs +++ b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Net; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; @@ -26,24 +27,23 @@ public class ZeebeIntegrationTestHelper private readonly string version; private readonly string audience; - private readonly bool withIdentity; + private bool withIdentity; private int count = 1; public readonly ILoggerFactory LoggerFactory; private IContainer postgresContainer; private IContainer keycloakContainer; private IContainer identityContainer; - private ZeebeIntegrationTestHelper(string version, bool withIdentity = false) + private ZeebeIntegrationTestHelper(string version) { this.version = version; - this.withIdentity = withIdentity; audience = Guid.NewGuid().ToString(); LoggerFactory = new NLogLoggerFactory(); } - public static ZeebeIntegrationTestHelper Latest(bool withIdentity = false) + public static ZeebeIntegrationTestHelper Latest() { - return new ZeebeIntegrationTestHelper(LatestVersion, withIdentity); + return new ZeebeIntegrationTestHelper(LatestVersion); } public ZeebeIntegrationTestHelper WithPartitionCount(int count) @@ -57,6 +57,12 @@ public static ZeebeIntegrationTestHelper OfVersion(string version) return new ZeebeIntegrationTestHelper(version); } + public ZeebeIntegrationTestHelper WithIdentity() + { + withIdentity = true; + return this; + } + public async Task SetupIntegrationTest() { TestcontainersSettings.Logger = LoggerFactory.CreateLogger(); @@ -74,11 +80,11 @@ public async Task SetupIntegrationTest() identityContainer = CreateIdentityContainer(network); await identityContainer.StartAsync(); - zeebeContainer = CreateZeebeContainer(true, network); + zeebeContainer = CreateZeebeContainer(network); } else { - zeebeContainer = CreateZeebeContainer(false); + zeebeContainer = CreateZeebeContainer(); } await zeebeContainer.StartAsync(); @@ -114,14 +120,15 @@ public async Task TearDownIntegrationTest() zeebeContainer = null; } - private IContainer CreateZeebeContainer(bool withKeycloak, INetwork network = null) + private IContainer CreateZeebeContainer(INetwork network = null) { var containerBuilder = new ContainerBuilder() .WithImage(new DockerImage("camunda", "zeebe", version)) .WithPortBinding(ZeebePort, true) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) .WithEnvironment("ZEEBE_BROKER_CLUSTER_PARTITIONSCOUNT", count.ToString()); - if (withKeycloak) + if (withIdentity) { containerBuilder = containerBuilder.WithEnvironment("ZEEBE_BROKER_GATEWAY_SECURITY_AUTHENTICATION_MODE", "identity") @@ -152,6 +159,7 @@ private IContainer CreatePostgresContainer(INetwork network) .WithEnvironment("POSTGRES_PASSWORD", "#3]O?4RGj)DE7Z!9SA5") .WithNetwork(network) .WithAutoRemove(true) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)); return containerBuilder.Build(); @@ -160,7 +168,7 @@ private IContainer CreatePostgresContainer(INetwork network) private IContainer CreateIdentityContainer(INetwork network) { var containerBuilder = new ContainerBuilder() - .WithImage(new DockerImage("camunda", "identity", "8.3.0")) + .WithImage(new DockerImage("camunda", "identity", version)) // identity and zeebe will have the same version .WithName("integration-identity") .WithExposedPort("8084") .WithPortBinding("8084", "8084") @@ -183,6 +191,7 @@ private IContainer CreateIdentityContainer(INetwork network) .WithEnvironment("KEYCLOAK_CLIENTS_0_PERMISSIONS_0_DEFINITION", "write:*") .WithEnvironment("RESOURCE_PERMISSIONS_ENABLED", "false") .WithAutoRemove(true) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) .WithNetwork(network); @@ -202,6 +211,7 @@ private IContainer CreateKeyCloakContainer(INetwork network) .WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", "admin") .WithNetwork(network) .WithAutoRemove(true) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request.ForPort(8080).ForPath("/auth").ForStatusCode(HttpStatusCode.OK))); @@ -231,9 +241,12 @@ public IZeebeClient CreateAuthenticatedZeebeClient() .UseGatewayAddress(host) .UseTransportEncryption() .AllowUntrustedCertificates() - .UseAccessTokenSupplier(new OAuth2TokenProvider( - $"http://{keycloakContainer.Hostname}:{keycloakContainer.GetMappedPublicPort(8080)}/auth/realms/camunda-platform/protocol/openid-connect/token", - "zeebe", "sddh123865WUS)(1%!", audience)).Build(); + .UseAccessTokenSupplier( + new CamundaCloudTokenProviderBuilder() + .UseAuthServer($"http://{keycloakContainer.Hostname}:{keycloakContainer.GetMappedPublicPort(8080)}/auth/realms/camunda-platform/protocol/openid-connect/token") + .UseClientId("zeebe") + .UseClientSecret("sddh123865WUS)(1%!") + .UseAudience(audience).Build()).Build(); } private async Task AwaitBrokerReadiness() @@ -249,7 +262,7 @@ private async Task AwaitBrokerReadiness() { try { - var topology = await client.TopologyRequest().Send(TimeSpan.FromSeconds(1)); + var topology = await zeebeClient.TopologyRequest().Send(TimeSpan.FromSeconds(1)); ready = topology.Brokers[0].Partitions.Count >= count; topologyErrorLogger.LogInformation("Requested topology [retries {Retries}], got '{Topology}'", retries, topology); } From b8e95088663d829c3efd525af1fe07a5bc781fc8 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 15:03:46 +0100 Subject: [PATCH 19/21] fix: set logging correctly --- Client/Impl/Builder/CamundaCloudTokenProvider.cs | 6 +++--- Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Client/Impl/Builder/CamundaCloudTokenProvider.cs b/Client/Impl/Builder/CamundaCloudTokenProvider.cs index 305396ee..d30352f7 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -30,10 +30,10 @@ internal CamundaCloudTokenProvider( string clientSecret, string audience, string path = null, - ILogger logger = null) + ILoggerFactory loggerFactory = null) { - persistedAccessTokenCache = new PersistedAccessTokenCache(path ?? ZeebeRootPath, FetchAccessToken); - this.logger = logger; + persistedAccessTokenCache = new PersistedAccessTokenCache(path ?? ZeebeRootPath, FetchAccessToken, loggerFactory?.CreateLogger()); + this.logger = loggerFactory?.CreateLogger(); this.authServer = authServer; this.clientId = clientId; this.clientSecret = clientSecret; diff --git a/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs b/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs index e0338de4..e8bbaa7b 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProviderBuilder.cs @@ -95,7 +95,7 @@ public CamundaCloudTokenProvider Build() clientSecret, audience, path, - loggerFactory?.CreateLogger()); + loggerFactory); } } } From d44b524f4869fb13921bf031ffdd6838c2888fe9 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 15:20:37 +0100 Subject: [PATCH 20/21] refactor: remove unused class --- Client/Impl/Builder/BaseTokenProvider.cs | 86 ------------------------ 1 file changed, 86 deletions(-) delete mode 100644 Client/Impl/Builder/BaseTokenProvider.cs diff --git a/Client/Impl/Builder/BaseTokenProvider.cs b/Client/Impl/Builder/BaseTokenProvider.cs deleted file mode 100644 index 55d135e0..00000000 --- a/Client/Impl/Builder/BaseTokenProvider.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Zeebe.Client.Api.Builder; -using Zeebe.Client.Impl.Misc; - -namespace Zeebe.Client.Impl.Builder; - -public abstract class BaseTokenProvider : IAccessTokenSupplier -{ - protected Dictionary CachedCredentials { get; set; } - - private static readonly string ZeebeRootPath = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".zeebe"); - - private readonly string zeebeTokenFileName; - private readonly ILogger logger; - - public string TokenStoragePath { get; set; } = ZeebeRootPath; - public string Audience { get; set; } - private string TokenFileName => TokenStoragePath + Path.DirectorySeparatorChar + zeebeTokenFileName; - - public BaseTokenProvider(string cachingTokenFileName, string audience, ILogger logger = null) - { - zeebeTokenFileName = cachingTokenFileName; - Audience = audience; - this.logger = logger; - } - - public Task GetAccessTokenForRequestAsync( - string authUri = null, - CancellationToken cancellationToken = default(CancellationToken)) - { - // check in memory - AccessToken currentAccessToken; - if (CachedCredentials.TryGetValue(Audience, out currentAccessToken)) - { - logger?.LogTrace("Use in memory access token."); - return GetValidToken(currentAccessToken); - } - - // check if token file exists - var useCachedFileToken = File.Exists(TokenFileName); - if (useCachedFileToken) - { - logger?.LogTrace("Read cached access token from {tokenFileName}", TokenFileName); - // read token - var content = File.ReadAllText(TokenFileName); - CachedCredentials = JsonConvert.DeserializeObject>(content); - if (CachedCredentials.TryGetValue(Audience, out currentAccessToken)) - { - logger?.LogTrace("Found access token in credentials file."); - return GetValidToken(currentAccessToken); - } - } - - // request token - return RequestAccessTokenAsync(); - } - - private Task GetValidToken(AccessToken currentAccessToken) - { - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var dueDate = currentAccessToken.DueDate; - if (now < dueDate) - { - // still valid - return Task.FromResult(currentAccessToken.Token); - } - - logger?.LogTrace("Access token is no longer valid (now: {now} > dueTime: {dueTime}), request new one.", now, - dueDate); - return RequestAccessTokenAsync(); - } - - protected void WriteCredentials() - { - File.WriteAllText(TokenFileName, JsonConvert.SerializeObject(CachedCredentials)); - } - - protected abstract Task RequestAccessTokenAsync(); -} \ No newline at end of file From f595c1e6850ce5a044a09e1233b5777063124f92 Mon Sep 17 00:00:00 2001 From: Christopher Zell Date: Fri, 15 Dec 2023 15:56:02 +0100 Subject: [PATCH 21/21] test: fix ports of identity and others --- Client.IntegrationTests/ZeebeIntegrationTestHelper.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs index 1d89e795..c48548cf 100644 --- a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs +++ b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs @@ -20,6 +20,8 @@ public class ZeebeIntegrationTestHelper public const string LatestVersion = "8.3.0"; private const ushort ZeebePort = 26500; + private const ushort KeycloakPort = 8080; + private const ushort IdentityPort = 8084; private IContainer zeebeContainer; @@ -170,9 +172,8 @@ private IContainer CreateIdentityContainer(INetwork network) var containerBuilder = new ContainerBuilder() .WithImage(new DockerImage("camunda", "identity", version)) // identity and zeebe will have the same version .WithName("integration-identity") - .WithExposedPort("8084") - .WithPortBinding("8084", "8084") - .WithEnvironment("SERVER_PORT", "8084") + .WithPortBinding(IdentityPort, true) + .WithEnvironment("SERVER_PORT", IdentityPort.ToString()) .WithEnvironment("IDENTITY_RETRY_DELAY_SECONDS", "30") .WithEnvironment("KEYCLOAK_URL", "http://integration-keycloak:8080/auth") .WithEnvironment("IDENTITY_AUTH_PROVIDER_BACKEND_URL", @@ -203,7 +204,7 @@ private IContainer CreateKeyCloakContainer(INetwork network) var containerBuilder = new ContainerBuilder() .WithImage(new DockerImage("bitnami", "keycloak", "21.1.2")) .WithName("integration-keycloak") - .WithPortBinding("18080", "8080") + .WithPortBinding("8080", true) .WithEnvironment("KEYCLOAK_HTTP_RELATIVE_PATH", "/auth") .WithEnvironment("KEYCLOAK_DATABASE_HOST", "integration-postgres") .WithEnvironment("KEYCLOAK_DATABASE_PASSWORD", "#3]O?4RGj)DE7Z!9SA5") @@ -243,7 +244,7 @@ public IZeebeClient CreateAuthenticatedZeebeClient() .AllowUntrustedCertificates() .UseAccessTokenSupplier( new CamundaCloudTokenProviderBuilder() - .UseAuthServer($"http://{keycloakContainer.Hostname}:{keycloakContainer.GetMappedPublicPort(8080)}/auth/realms/camunda-platform/protocol/openid-connect/token") + .UseAuthServer($"http://{keycloakContainer.Hostname}:{keycloakContainer.GetMappedPublicPort(KeycloakPort)}/auth/realms/camunda-platform/protocol/openid-connect/token") .UseClientId("zeebe") .UseClientSecret("sddh123865WUS)(1%!") .UseAudience(audience).Build()).Build();