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/OAuthIntegrationTest.cs b/Client.IntegrationTests/OAuthIntegrationTest.cs new file mode 100644 index 00000000..20546b04 --- /dev/null +++ b/Client.IntegrationTests/OAuthIntegrationTest.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Grpc.Core; +using NUnit.Framework; + +namespace Client.IntegrationTests; + +[TestFixture] +public class OAuthIntegrationTest +{ + private readonly ZeebeIntegrationTestHelper testHelper = ZeebeIntegrationTestHelper.Latest().WithIdentity(); + + [OneTimeSetUp] + public async Task Setup() + { + await testHelper.SetupIntegrationTest(); + } + + [OneTimeTearDown] + public async Task Stop() + { + await testHelper.TearDownIntegrationTest(); + } + + [Test] + public async Task ShouldSendRequestAndNotFailingWithAuthenticatedClient() + { + var authenticatedZeebeClient = testHelper.CreateAuthenticatedZeebeClient(); + var topology = await authenticatedZeebeClient.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 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/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..c48548cf 100644 --- a/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs +++ b/Client.IntegrationTests/ZeebeIntegrationTestHelper.cs @@ -1,14 +1,17 @@ using System; using System.IO; -using System.Threading; +using System.Net; +using System.Runtime.CompilerServices; 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 { @@ -17,17 +20,26 @@ 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 container; + + private IContainer zeebeContainer; private IZeebeClient client; private readonly string version; + private readonly string audience; + private bool withIdentity; private int count = 1; public readonly ILoggerFactory LoggerFactory; + private IContainer postgresContainer; + private IContainer keycloakContainer; + private IContainer identityContainer; private ZeebeIntegrationTestHelper(string version) { this.version = version; + audience = Guid.NewGuid().ToString(); LoggerFactory = new NLogLoggerFactory(); } @@ -47,13 +59,47 @@ 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(); - 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(network); + } + else + { + zeebeContainer = CreateZeebeContainer(); + } + + await zeebeContainer.StartAsync(); + + if (withIdentity) + { + client = CreateAuthenticatedZeebeClient(); + } + else + { + client = CreateZeebeClient(); + } + await AwaitBrokerReadiness(); return client; } @@ -62,24 +108,122 @@ 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(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()) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) + .WithEnvironment("ZEEBE_BROKER_CLUSTER_PARTITIONSCOUNT", count.ToString()); + + if (withIdentity) + { + 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(); + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)); + + return containerBuilder.Build(); } - private IZeebeClient CreateZeebeClient() + 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") + .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", + "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) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) + .WithNetwork(network); + + + return containerBuilder.Build(); + } + + private IContainer CreateKeyCloakContainer(INetwork network) + { + var containerBuilder = new ContainerBuilder() + .WithImage(new DockerImage("bitnami", "keycloak", "21.1.2")) + .WithName("integration-keycloak") + .WithPortBinding("8080", true) + .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) + .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole()) + .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 +232,27 @@ 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 CamundaCloudTokenProviderBuilder() + .UseAuthServer($"http://{keycloakContainer.Hostname}:{keycloakContainer.GetMappedPublicPort(KeycloakPort)}/auth/realms/camunda-platform/protocol/openid-connect/token") + .UseClientId("zeebe") + .UseClientSecret("sddh123865WUS)(1%!") + .UseAudience(audience).Build()).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; @@ -101,7 +263,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); } diff --git a/Client.UnitTests/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/CamundaCloudTokenProviderTest.cs deleted file mode 100644 index ee16cc5b..00000000 --- a/Client.UnitTests/CamundaCloudTokenProviderTest.cs +++ /dev/null @@ -1,320 +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 Newtonsoft.Json.Linq; -using NLog; -using NUnit.Framework; -using Zeebe.Client.Impl.Builder; - -namespace Zeebe.Client -{ - [TestFixture] - public class CamundaCloudTokenProviderTest - { - private HttpMessageHandlerStub MessageHandlerStub { get; set; } - private CamundaCloudTokenProvider 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 CamundaCloudTokenProviderBuilder() - .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(); - 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); - - 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 CamundaCloudTokenProviderBuilder() - .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 CamundaCloudTokenProviderBuilder() - .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); - } - } -} \ No newline at end of file diff --git a/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs new file mode 100644 index 00000000..0f743c13 --- /dev/null +++ b/Client.UnitTests/Impl/Builder/CamundaCloudTokenProviderTest.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace Zeebe.Client.Impl.Builder +{ + [TestFixture] + public class CamundaCloudTokenProviderTest + { + private HttpMessageHandlerStub MessageHandlerStub { get; set; } + private CamundaCloudTokenProvider 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"; + TokenStoragePath = Path.GetTempPath() + ".zeebe/"; + TokenProvider = new CamundaCloudTokenProviderBuilder() + .UseAuthServer(_requestUri) + .UseClientId(_clientId) + .UseClientSecret(_clientSecret) + .UseAudience(_audience) + .UsePath(TokenStoragePath) + .Build(); + + MessageHandlerStub = new HttpMessageHandlerStub(); + TokenProvider.SetHttpMessageHandler(MessageHandlerStub); + 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(); + 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) + { + 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 ShouldNotThrowObjectDisposedExceptionWhenTokenExpires() + { + // given + ExpiresIn = 0; + await TokenProvider.GetAccessTokenForRequestAsync(); + + // when + Assert.DoesNotThrowAsync(async () => await TokenProvider.GetAccessTokenForRequestAsync()); + + // then + Assert.AreEqual(2, MessageHandlerStub.RequestCount); + } + } +} \ No newline at end of file diff --git a/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs new file mode 100644 index 00000000..d27d8391 --- /dev/null +++ b/Client.UnitTests/Impl/Misc/PersistedAccessTokenCacheTest.cs @@ -0,0 +1,212 @@ +// +// 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 PersistedAccessTokenCacheTest +{ + 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 PersistedAccessTokenCache(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 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-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 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 b83d82e0..d30352f7 100644 --- a/Client/Impl/Builder/CamundaCloudTokenProvider.cs +++ b/Client/Impl/Builder/CamundaCloudTokenProvider.cs @@ -1,25 +1,17 @@ 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 { - 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"); @@ -30,24 +22,23 @@ public class CamundaCloudTokenProvider : IAccessTokenSupplier, 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) + string path = null, + ILoggerFactory loggerFactory = null) { - this.logger = logger; + persistedAccessTokenCache = new PersistedAccessTokenCache(path ?? ZeebeRootPath, FetchAccessToken, loggerFactory?.CreateLogger()); + this.logger = loggerFactory?.CreateLogger(); this.authServer = authServer; this.clientId = clientId; this.clientSecret = clientSecret; this.audience = audience; - - // default client handler httpClient = new HttpClient(new HttpClientHandler(), disposeHandler: false); - TokenStoragePath = ZeebeRootPath; - Credentials = new Dictionary(); } public static CamundaCloudTokenProviderBuilder Builder() @@ -55,136 +46,55 @@ 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 \ - // --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/ - - private 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 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"]; - - var expiresInMilliSeconds = (long)jsonResult["expires_in"] * 1_000L; - var dueDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + expiresInMilliSeconds; - var token = new AccessToken(accessToken, dueDate); + // 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: + // + // { + // "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/ + var result = await httpResponseMessage.Content.ReadAsStringAsync(); + var token = AccessToken.FromJson(result); + logger?.LogDebug("Received access token for {Audience}", audience); return token; } - public class AccessToken + private FormUrlEncodedContent BuildRequestAccessTokenContent() { - public string Token { get; set; } - public long DueDate { get; set; } - - public AccessToken(string token, long dueDate) - { - Token = token; - DueDate = dueDate; - } - - public override string ToString() + var formContent = new FormUrlEncodedContent(new[] { - return $"{nameof(Token)}: {Token}, {nameof(DueDate)}: {DueDate}"; - } + 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() @@ -192,5 +102,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..e8bbaa7b 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,7 +94,8 @@ public CamundaCloudTokenProvider Build() clientId, clientSecret, audience, - loggerFactory?.CreateLogger()); + path, + loggerFactory); } } } diff --git a/Client/Impl/Misc/AccessToken.cs b/Client/Impl/Misc/AccessToken.cs new file mode 100644 index 00000000..8046ff15 --- /dev/null +++ b/Client/Impl/Misc/AccessToken.cs @@ -0,0 +1,35 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace Zeebe.Client.Impl.Misc; + +/// +/// AccessToken, which consist of an token and a dueDate (expiryDate). +/// +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 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/PersistedAccessTokenCache.cs b/Client/Impl/Misc/PersistedAccessTokenCache.cs new file mode 100644 index 00000000..add57200 --- /dev/null +++ b/Client/Impl/Misc/PersistedAccessTokenCache.cs @@ -0,0 +1,97 @@ +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 PersistedAccessTokenCache : IAccessTokenCache +{ + 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 IAccessTokenCache.AccessTokenResolverAsync accessTokenFetcherAsync; + + private readonly string tokenStoragePath; + private string TokenFileName => Path.Combine(tokenStoragePath, ZeebeTokenFileName); + + public PersistedAccessTokenCache(string path, IAccessTokenCache.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)); + } +} \ No newline at end of file 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 +}