diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index a2301b464..a9345b215 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -5,6 +5,7 @@ using System.Net; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; +using System.Text.Json.Nodes; namespace k8s { @@ -277,6 +278,10 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext SkipTlsVerify = clusterDetails.ClusterEndpoint.SkipTlsVerify; TlsServerName = clusterDetails.ClusterEndpoint.TlsServerName; + // Reset CA data so it always reflects the cluster currently being resolved + // and is never carried over from a prior state. + CertificateAuthorityData = null; + if (!Uri.TryCreate(Host, UriKind.Absolute, out var uri)) { throw new KubeConfigException($"Bad server host URL `{Host}` (cannot be parsed)"); @@ -306,26 +311,28 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext { if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthorityData)) { - var data = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + CertificateAuthorityData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; #if NET9_0_OR_GREATER - SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(Convert.FromBase64String(data))); + SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(Convert.FromBase64String(CertificateAuthorityData))); #else string nullPassword = null; // This null password is to change the constructor to fix this KB: // https://support.microsoft.com/en-us/topic/kb5025823-change-in-how-net-applications-import-x-509-certificates-bf81c936-af2b-446e-9f7a-016f4713b46b - SslCaCerts = new X509Certificate2Collection(new X509Certificate2(Convert.FromBase64String(data), nullPassword)); + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(Convert.FromBase64String(CertificateAuthorityData), nullPassword)); #endif } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { + var caPath = GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority); + CertificateAuthorityData = Convert.ToBase64String(File.ReadAllBytes(caPath)); + + // File-path loaders auto-detect cert format (PEM/DER/PFX), which the + // byte-based APIs do not reliably do on pre-.NET 9. The second read is + // intentional to preserve format flexibility for SslCaCerts. #if NET9_0_OR_GREATER - SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificateFromFile(GetFullPath( - k8SConfig, - clusterDetails.ClusterEndpoint.CertificateAuthority))); + SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificateFromFile(caPath)); #else - SslCaCerts = new X509Certificate2Collection(new X509Certificate2(GetFullPath( - k8SConfig, - clusterDetails.ClusterEndpoint.CertificateAuthority))); + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(caPath)); #endif } } @@ -416,7 +423,19 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) throw new KubeConfigException("External command execution missing ApiVersion key"); } - var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution); + ClusterEndpoint clusterEndpoint = null; + if (userDetails.UserCredentials.ExternalExecution.ProvideClusterInfo) + { + clusterEndpoint = new ClusterEndpoint + { + Server = this.Host, + SkipTlsVerify = this.SkipTlsVerify, + TlsServerName = this.TlsServerName, + CertificateAuthorityData = this.CertificateAuthorityData, + }; + } + + var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution, clusterEndpoint); AccessToken = response.Status.Token; // When reading ClientCertificateData from a config file it will be base64 encoded, and code later in the system (see CertUtils.GeneratePfx) // expects ClientCertificateData and ClientCertificateKeyData to be base64 encoded because of this. However the string returned by external @@ -429,7 +448,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) // TODO: support client certificates here too. if (AccessToken != null) { - TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution); + TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution, clusterEndpoint); } } @@ -440,16 +459,79 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) } } + /// + /// Converts a resolved into the + /// spec.cluster JSON representation defined by the exec credential plugin + /// protocol (client.authentication.k8s.io/v1). Returns null if + /// is null. + /// + /// + /// The AOT does not include Extensions (dynamic types + /// are incompatible with AOT), so spec.cluster.config is not populated. + /// + /// + internal static JsonNode ToExecClusterInfo(ClusterEndpoint cluster) + { + if (cluster == null) + { + return null; + } + + var node = new JsonObject + { + ["server"] = cluster.Server, + }; + + if (cluster.SkipTlsVerify) + { + node["insecure-skip-tls-verify"] = true; + } + + if (!string.IsNullOrEmpty(cluster.CertificateAuthorityData)) + { + node["certificate-authority-data"] = cluster.CertificateAuthorityData; + } + + if (!string.IsNullOrEmpty(cluster.TlsServerName)) + { + node["tls-server-name"] = cluster.TlsServerName; + } + + return node; + } + public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null) + { + return CreateRunnableExternalProcess(config, captureStdError, null); + } + + public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError, ClusterEndpoint cluster) { if (config == null) { throw new ArgumentNullException(nameof(config)); } + var spec = new JsonObject { ["interactive"] = Environment.UserInteractive }; + if (config.ProvideClusterInfo) + { + var clusterNode = ToExecClusterInfo(cluster); + if (clusterNode != null) + { + spec["cluster"] = clusterNode; + } + } + + var execInfo = new JsonObject + { + ["apiVersion"] = config.ApiVersion, + ["kind"] = "ExecCredentials", + ["spec"] = spec, + }; + var process = new Process(); - process.StartInfo.EnvironmentVariables.Add("KUBERNETES_EXEC_INFO", $"{{ \"apiVersion\":\"{config.ApiVersion}\",\"kind\":\"ExecCredentials\",\"spec\":{{ \"interactive\":{Environment.UserInteractive.ToString().ToLower()} }} }}"); + process.StartInfo.EnvironmentVariables.Add("KUBERNETES_EXEC_INFO", execInfo.ToJsonString()); if (config.EnvironmentVariables != null) { foreach (var configEnvironmentVariable in config.EnvironmentVariables) @@ -493,6 +575,11 @@ public static Process CreateRunnableExternalProcess(ExternalExecution config, Ev /// The token, client certificate data, and the client key data received from the external command execution /// public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + { + return ExecuteExternalCommand(config, null); + } + + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ClusterEndpoint cluster) { if (config == null) { @@ -500,7 +587,7 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co } var captureStdError = ExecStdError; - var process = CreateRunnableExternalProcess(config, captureStdError); + var process = CreateRunnableExternalProcess(config, captureStdError, cluster); try { diff --git a/src/KubernetesClient/Authentication/ExecTokenProvider.cs b/src/KubernetesClient/Authentication/ExecTokenProvider.cs index 26bc9b961..4f6487105 100644 --- a/src/KubernetesClient/Authentication/ExecTokenProvider.cs +++ b/src/KubernetesClient/Authentication/ExecTokenProvider.cs @@ -6,11 +6,18 @@ namespace k8s.Authentication public class ExecTokenProvider : ITokenProvider { private readonly ExternalExecution exec; + private readonly ClusterEndpoint cluster; private ExecCredentialResponse response; public ExecTokenProvider(ExternalExecution exec) + : this(exec, null) + { + } + + public ExecTokenProvider(ExternalExecution exec, ClusterEndpoint cluster) { this.exec = exec; + this.cluster = cluster; } private bool NeedsRefresh() @@ -41,7 +48,7 @@ public async Task GetAuthenticationHeaderAsync(Cancel private async Task RefreshToken() { response = - await Task.Run(() => KubernetesClientConfiguration.ExecuteExternalCommand(this.exec)).ConfigureAwait(false); + await Task.Run(() => KubernetesClientConfiguration.ExecuteExternalCommand(this.exec, this.cluster)).ConfigureAwait(false); } } } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index ca9e206bd..d1b42686a 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -5,11 +5,14 @@ using System.Net; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json.Nodes; namespace k8s { public partial class KubernetesClientConfiguration { + internal const string ExecExtensionName = "client.authentication.k8s.io/exec"; + /// /// kubeconfig Default Location /// @@ -277,6 +280,10 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext SkipTlsVerify = clusterDetails.ClusterEndpoint.SkipTlsVerify; TlsServerName = clusterDetails.ClusterEndpoint.TlsServerName; + // Reset CA data so it always reflects the cluster currently being resolved + // and is never carried over from a prior state. + CertificateAuthorityData = null; + if (!Uri.TryCreate(Host, UriKind.Absolute, out var uri)) { throw new KubeConfigException($"Bad server host URL `{Host}` (cannot be parsed)"); @@ -306,15 +313,16 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext { if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthorityData)) { - var data = clusterDetails.ClusterEndpoint.CertificateAuthorityData; - var pemText = Encoding.UTF8.GetString(Convert.FromBase64String(data)); + CertificateAuthorityData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + var pemText = Encoding.UTF8.GetString(Convert.FromBase64String(CertificateAuthorityData)); SslCaCerts = CertUtils.LoadFromPemText(pemText); } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { - SslCaCerts = CertUtils.LoadPemFileCert(GetFullPath( - k8SConfig, - clusterDetails.ClusterEndpoint.CertificateAuthority)); + var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); + CertificateAuthorityData = Convert.ToBase64String(caBytes); + var pemText = Encoding.UTF8.GetString(caBytes); + SslCaCerts = CertUtils.LoadFromPemText(pemText); } } } @@ -426,7 +434,25 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) throw new KubeConfigException("External command execution missing ApiVersion key"); } - var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution); + ClusterEndpoint clusterEndpoint = null; + if (userDetails.UserCredentials.ExternalExecution.ProvideClusterInfo) + { + var clusterDetails = k8SConfig.Clusters.FirstOrDefault(c => c.Name.Equals( + activeContext.ContextDetails.Cluster, + StringComparison.OrdinalIgnoreCase)); + var rawCluster = clusterDetails?.ClusterEndpoint; + + clusterEndpoint = new ClusterEndpoint + { + Server = this.Host, + SkipTlsVerify = this.SkipTlsVerify, + TlsServerName = this.TlsServerName, + CertificateAuthorityData = this.CertificateAuthorityData, + Extensions = rawCluster?.Extensions, + }; + } + + var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution, clusterEndpoint); AccessToken = response.Status.Token; // When reading ClientCertificateData from a config file it will be base64 encoded, and code later in the system (see CertUtils.GeneratePfx) // expects ClientCertificateData and ClientCertificateKeyData to be base64 encoded because of this. However the string returned by external @@ -439,7 +465,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) // TODO: support client certificates here too. if (AccessToken != null) { - TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution); + TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution, clusterEndpoint); } } @@ -450,23 +476,86 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) } } + /// + /// Converts a (kubeconfig model) into the + /// spec.cluster JSON representation defined by the exec credential plugin + /// protocol (client.authentication.k8s.io/v1). Returns null if + /// is null. + /// + /// + internal static JsonNode ToExecClusterInfo(ClusterEndpoint cluster) + { + if (cluster == null) + { + return null; + } + + var node = new JsonObject + { + ["server"] = cluster.Server, + }; + + if (cluster.SkipTlsVerify) + { + node["insecure-skip-tls-verify"] = true; + } + + if (!string.IsNullOrEmpty(cluster.CertificateAuthorityData)) + { + node["certificate-authority-data"] = cluster.CertificateAuthorityData; + } + + if (!string.IsNullOrEmpty(cluster.TlsServerName)) + { + node["tls-server-name"] = cluster.TlsServerName; + } + + var execExtension = cluster.Extensions? + .FirstOrDefault(e => e.Name == ExecExtensionName); + if (execExtension != null) + { + object extConfig = execExtension.Extension; + if (extConfig != null) + { + node["config"] = JsonSerializer.SerializeToNode(extConfig); + } + } + + return node; + } + public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null) + { + return CreateRunnableExternalProcess(config, captureStdError, null); + } + + public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError, ClusterEndpoint cluster) { if (config == null) { throw new ArgumentNullException(nameof(config)); } - var execInfo = new Dictionary + var spec = new JsonObject { ["interactive"] = Environment.UserInteractive }; + if (config.ProvideClusterInfo) { - { "apiVersion", config.ApiVersion }, - { "kind", "ExecCredentials" }, - { "spec", new Dictionary { { "interactive", Environment.UserInteractive } } }, + var clusterNode = ToExecClusterInfo(cluster); + if (clusterNode != null) + { + spec["cluster"] = clusterNode; + } + } + + var execInfo = new JsonObject + { + ["apiVersion"] = config.ApiVersion, + ["kind"] = "ExecCredentials", + ["spec"] = spec, }; var process = new Process(); - process.StartInfo.EnvironmentVariables.Add("KUBERNETES_EXEC_INFO", JsonSerializer.Serialize(execInfo)); + process.StartInfo.EnvironmentVariables.Add("KUBERNETES_EXEC_INFO", execInfo.ToJsonString()); if (config.EnvironmentVariables != null) { foreach (var configEnvironmentVariable in config.EnvironmentVariables) @@ -510,6 +599,11 @@ public static Process CreateRunnableExternalProcess(ExternalExecution config, Ev /// The token, client certificate data, and the client key data received from the external command execution /// public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + { + return ExecuteExternalCommand(config, null); + } + + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ClusterEndpoint cluster) { if (config == null) { @@ -517,7 +611,7 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co } var captureStdError = ExecStdError; - var process = CreateRunnableExternalProcess(config, captureStdError); + var process = CreateRunnableExternalProcess(config, captureStdError, cluster); try { diff --git a/src/KubernetesClient/KubernetesClientConfiguration.cs b/src/KubernetesClient/KubernetesClientConfiguration.cs index 6d05c913a..780230175 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.cs @@ -61,6 +61,12 @@ public partial class KubernetesClientConfiguration /// public string TlsServerName { get; set; } + /// + /// Gets the base64-encoded PEM certificate authority data, resolved from either + /// inline data or file path during cluster configuration. + /// + public string CertificateAuthorityData { get; set; } + /// /// Gets or sets the HTTP user agent. /// diff --git a/tests/KubernetesClient.Tests/ExternalExecutionTests.cs b/tests/KubernetesClient.Tests/ExternalExecutionTests.cs index f4b101f53..ea6b7a3b6 100644 --- a/tests/KubernetesClient.Tests/ExternalExecutionTests.cs +++ b/tests/KubernetesClient.Tests/ExternalExecutionTests.cs @@ -1,6 +1,6 @@ using k8s.KubeConfigModels; using System.Collections.Generic; -using System.Text.Json; +using System.Text.Json.Nodes; using Xunit; namespace k8s.Tests @@ -19,13 +19,192 @@ public void CreateRunnableExternalProcess() { new Dictionary { { "name", "testkey" }, { "value", "testvalue" } } }, }); - var actualExecInfo = JsonSerializer.Deserialize>(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); - Assert.Equal("testingversion", actualExecInfo["apiVersion"].ToString()); - Assert.Equal("ExecCredentials", actualExecInfo["kind"].ToString()); + var json = JsonNode.Parse(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); + Assert.Equal("testingversion", json["apiVersion"]?.GetValue()); + Assert.Equal("ExecCredentials", json["kind"]?.GetValue()); + Assert.False(json["spec"].AsObject().ContainsKey("cluster")); Assert.Equal("command", actual.StartInfo.FileName); Assert.Equal("arg1 arg2", actual.StartInfo.Arguments); Assert.Equal("testvalue", actual.StartInfo.EnvironmentVariables["testkey"]); } + + [Fact] + public void ToExecClusterInfoMapsFieldsCorrectly() + { + var cluster = new ClusterEndpoint + { + Server = "https://my-cluster.example.com:6443", + CertificateAuthorityData = "LS0tLS1CRUdJTi0t", + SkipTlsVerify = false, + TlsServerName = "my-cluster.example.com", + Extensions = new List + { + new NamedExtension + { + Name = KubernetesClientConfiguration.ExecExtensionName, + Extension = new Dictionary { { "audience", "06e3fbd18de8" } }, + }, + }, + }; + + var result = KubernetesClientConfiguration.ToExecClusterInfo(cluster); + + Assert.NotNull(result); + Assert.Equal("https://my-cluster.example.com:6443", result["server"]?.GetValue()); + Assert.False(result.AsObject().ContainsKey("insecure-skip-tls-verify")); + Assert.Equal("LS0tLS1CRUdJTi0t", result["certificate-authority-data"]?.GetValue()); + Assert.Equal("my-cluster.example.com", result["tls-server-name"]?.GetValue()); + Assert.Equal("06e3fbd18de8", result["config"]?["audience"]?.GetValue()); + } + + [Fact] + public void ToExecClusterInfoReturnsNullForNullCluster() + { + Assert.Null(KubernetesClientConfiguration.ToExecClusterInfo(null)); + } + + [Fact] + public void ToExecClusterInfoOmitsOptionalEmptyFields() + { + var cluster = new ClusterEndpoint + { + Server = "https://my-cluster.example.com", + SkipTlsVerify = false, + }; + + var result = KubernetesClientConfiguration.ToExecClusterInfo(cluster); + + Assert.NotNull(result); + Assert.Equal("https://my-cluster.example.com", result["server"]?.GetValue()); + Assert.False(result.AsObject().ContainsKey("insecure-skip-tls-verify")); + Assert.False(result.AsObject().ContainsKey("certificate-authority-data")); + Assert.False(result.AsObject().ContainsKey("tls-server-name")); + Assert.False(result.AsObject().ContainsKey("config")); + } + + [Fact] + public void CreateRunnableExternalProcessIncludesClusterWhenProvideClusterInfoIsTrue() + { + var cluster = new ClusterEndpoint + { + Server = "https://my-cluster.example.com", + SkipTlsVerify = false, + }; + + var actual = KubernetesClientConfiguration.CreateRunnableExternalProcess( + new ExternalExecution + { + ApiVersion = "client.authentication.k8s.io/v1", + Command = "my-credential-plugin", + ProvideClusterInfo = true, + }, + captureStdError: null, + cluster: cluster); + + var json = JsonNode.Parse(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); + Assert.Equal("https://my-cluster.example.com", json["spec"]?["cluster"]?["server"]?.GetValue()); + } + + [Fact] + public void CreateRunnableExternalProcessOmitsClusterWhenProvideClusterInfoIsFalse() + { + var cluster = new ClusterEndpoint + { + Server = "https://my-cluster.example.com", + SkipTlsVerify = false, + }; + + var actual = KubernetesClientConfiguration.CreateRunnableExternalProcess( + new ExternalExecution + { + ApiVersion = "client.authentication.k8s.io/v1", + Command = "my-credential-plugin", + ProvideClusterInfo = false, + }, + captureStdError: null, + cluster: cluster); + + var json = JsonNode.Parse(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); + Assert.False(json["spec"].AsObject().ContainsKey("cluster")); + } + + [Fact] + public void ToExecClusterInfoHandlesNestedExtensions() + { + var cluster = new ClusterEndpoint + { + Server = "https://my-cluster.example.com", + Extensions = new List + { + new NamedExtension + { + Name = KubernetesClientConfiguration.ExecExtensionName, + Extension = BuildNestedExtension(), + }, + }, + }; + + var result = KubernetesClientConfiguration.ToExecClusterInfo(cluster); + + Assert.NotNull(result); + Assert.Equal("06e3fbd18de8", result["config"]?["audience"]?.GetValue()); + Assert.Equal("value1", result["config"]?["nested"]?["key1"]?.GetValue()); + Assert.Equal("innervalue", result["config"]?["nested"]?["deep"]?["inner"]?.GetValue()); + Assert.Equal("a", result["config"]?["tags"]?[0]?.GetValue()); + Assert.Equal("b", result["config"]?["tags"]?[1]?.GetValue()); + Assert.Equal("c", result["config"]?["tags"]?[2]?.GetValue()); + } + + [Fact] + public void ToExecClusterInfoEmitsInsecureSkipTlsVerifyWhenTrue() + { + var cluster = new ClusterEndpoint + { + Server = "https://my-cluster.example.com", + SkipTlsVerify = true, + }; + + var result = KubernetesClientConfiguration.ToExecClusterInfo(cluster); + + Assert.NotNull(result); + Assert.True(result["insecure-skip-tls-verify"]?.GetValue()); + } + + [Fact] + public void CreateRunnableExternalProcessOmitsClusterWhenProvideClusterInfoIsTrueButClusterIsNull() + { + var actual = KubernetesClientConfiguration.CreateRunnableExternalProcess( + new ExternalExecution + { + ApiVersion = "client.authentication.k8s.io/v1", + Command = "my-credential-plugin", + ProvideClusterInfo = true, + }, + captureStdError: null, + cluster: null); + + var json = JsonNode.Parse(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); + Assert.False(json["spec"].AsObject().ContainsKey("cluster")); + } + + private static Dictionary BuildNestedExtension() + { + var deep = new Dictionary + { + { "inner", "innervalue" }, + }; + var nested = new Dictionary + { + { "key1", "value1" }, + { "deep", deep }, + }; + return new Dictionary + { + { "audience", "06e3fbd18de8" }, + { "nested", nested }, + { "tags", new List { "a", "b", "c" } }, + }; + } } }