From 3749bdf1bd4e777293a5848c0894a83fe7b3e9f9 Mon Sep 17 00:00:00 2001 From: Atharva Mutsaddi Date: Wed, 17 Jun 2026 11:59:31 +0530 Subject: [PATCH 1/6] Pass cluster info to exec credential plugins (provideClusterInfo) --- ...ubernetesClientConfiguration.ConfigFile.cs | 95 ++++++++-- .../Authentication/ExecTokenProvider.cs | 6 +- ...ubernetesClientConfiguration.ConfigFile.cs | 103 +++++++++-- .../KubernetesClientConfiguration.cs | 6 + .../ExternalExecutionTests.cs | 168 +++++++++++++++++- 5 files changed, 342 insertions(+), 36 deletions(-) diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index a2301b464..fee8a958a 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 { @@ -306,26 +307,24 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext { if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthorityData)) { - var data = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + CaData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; #if NET9_0_OR_GREATER - SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(Convert.FromBase64String(data))); + SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(Convert.FromBase64String(CaData))); #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(CaData), nullPassword)); #endif } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { + var caPath = GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority); + CaData = Convert.ToBase64String(File.ReadAllBytes(caPath)); #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 +415,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.CaData, + }; + } + + 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 +440,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 +451,70 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) } } - public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null) + /// + /// 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, ClusterEndpoint cluster = null) { if (config == null) { throw new ArgumentNullException(nameof(config)); } + var spec = new JsonObject { ["interactive"] = Environment.UserInteractive }; + if (config.ProvideClusterInfo) + { + spec["cluster"] = ToExecClusterInfo(cluster); + } + + 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) @@ -492,7 +557,7 @@ 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) + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ClusterEndpoint cluster = null) { if (config == null) { @@ -500,7 +565,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..e68e32417 100644 --- a/src/KubernetesClient/Authentication/ExecTokenProvider.cs +++ b/src/KubernetesClient/Authentication/ExecTokenProvider.cs @@ -6,11 +6,13 @@ namespace k8s.Authentication public class ExecTokenProvider : ITokenProvider { private readonly ExternalExecution exec; + private readonly ClusterEndpoint cluster; private ExecCredentialResponse response; - public ExecTokenProvider(ExternalExecution exec) + public ExecTokenProvider(ExternalExecution exec, ClusterEndpoint cluster = null) { this.exec = exec; + this.cluster = cluster; } private bool NeedsRefresh() @@ -41,7 +43,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..a2b3ecd8b 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -5,6 +5,7 @@ using System.Net; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json.Nodes; namespace k8s { @@ -306,15 +307,15 @@ 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)); + CaData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + var pemText = Encoding.UTF8.GetString(Convert.FromBase64String(CaData)); SslCaCerts = CertUtils.LoadFromPemText(pemText); } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { - SslCaCerts = CertUtils.LoadPemFileCert(GetFullPath( - k8SConfig, - clusterDetails.ClusterEndpoint.CertificateAuthority)); + var caPath = GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority); + CaData = Convert.ToBase64String(File.ReadAllBytes(caPath)); + SslCaCerts = CertUtils.LoadPemFileCert(caPath); } } } @@ -426,7 +427,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.CaData, + 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 +458,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 +469,77 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) } } - public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null) + /// + /// 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 == "client.authentication.k8s.io/exec"); + if (execExtension != null) + { + object extConfig = execExtension.Extension; + if (extConfig != null) + { + node["config"] = JsonNode.Parse(JsonSerializer.Serialize(extConfig)); + } + } + + return node; + } + + public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null, ClusterEndpoint cluster = null) { if (config == null) { throw new ArgumentNullException(nameof(config)); } - var execInfo = new Dictionary + var spec = new JsonObject { ["interactive"] = Environment.UserInteractive }; + if (config.ProvideClusterInfo) + { + spec["cluster"] = ToExecClusterInfo(cluster); + } + + var execInfo = new JsonObject { - { "apiVersion", config.ApiVersion }, - { "kind", "ExecCredentials" }, - { "spec", new Dictionary { { "interactive", Environment.UserInteractive } } }, + ["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) @@ -509,7 +582,7 @@ 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) + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ClusterEndpoint cluster = null) { if (config == null) { @@ -517,7 +590,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..99a01fccc 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 CaData { 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..b49d3d157 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,173 @@ 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 = "client.authentication.k8s.io/exec", + 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, + }, + 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, + }, + 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 = "client.authentication.k8s.io/exec", + 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()); + } + + 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" } }, + }; + } } } From 5e2729f783c8d8bd995845ac5cdbe792a71a86e9 Mon Sep 17 00:00:00 2001 From: Atharva Mutsaddi Date: Wed, 17 Jun 2026 13:55:32 +0530 Subject: [PATCH 2/6] fix exec cluster info: protocol conformance and binary compatibility - Omit spec.cluster key when ProvideClusterInfo is false (don't emit null) - Omit insecure-skip-tls-verify when false (omitempty per spec) - Resolve CA from file path (mirroring client-go's dataFromSliceOrFile) - Use resolved Host/TlsServerName/CaData instead of raw kubeconfig values - Preserve binary compatibility via method overloads - Guard against cluster=null when ProvideClusterInfo=true - Add nested extension and edge-case tests --- ...ubernetesClientConfiguration.ConfigFile.cs | 29 ++++++++++++++----- .../Authentication/ExecTokenProvider.cs | 7 ++++- ...ubernetesClientConfiguration.ConfigFile.cs | 27 +++++++++++++---- .../ExternalExecutionTests.cs | 19 ++++++++++++ 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index fee8a958a..f9a887fd3 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -319,12 +319,13 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { - var caPath = GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority); - CaData = Convert.ToBase64String(File.ReadAllBytes(caPath)); + var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); + CaData = Convert.ToBase64String(caBytes); #if NET9_0_OR_GREATER - SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificateFromFile(caPath)); + SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(caBytes)); #else - SslCaCerts = new X509Certificate2Collection(new X509Certificate2(caPath)); + string nullPassword = null; + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(caBytes, nullPassword)); #endif } } @@ -492,7 +493,12 @@ internal static JsonNode ToExecClusterInfo(ClusterEndpoint cluster) return node; } - public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null, ClusterEndpoint cluster = null) + 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) { @@ -502,7 +508,11 @@ public static Process CreateRunnableExternalProcess(ExternalExecution config, Ev var spec = new JsonObject { ["interactive"] = Environment.UserInteractive }; if (config.ProvideClusterInfo) { - spec["cluster"] = ToExecClusterInfo(cluster); + var clusterNode = ToExecClusterInfo(cluster); + if (clusterNode != null) + { + spec["cluster"] = clusterNode; + } } var execInfo = new JsonObject @@ -557,7 +567,12 @@ 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, ClusterEndpoint cluster = null) + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + { + return ExecuteExternalCommand(config, null); + } + + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ClusterEndpoint cluster) { if (config == null) { diff --git a/src/KubernetesClient/Authentication/ExecTokenProvider.cs b/src/KubernetesClient/Authentication/ExecTokenProvider.cs index e68e32417..4f6487105 100644 --- a/src/KubernetesClient/Authentication/ExecTokenProvider.cs +++ b/src/KubernetesClient/Authentication/ExecTokenProvider.cs @@ -9,7 +9,12 @@ public class ExecTokenProvider : ITokenProvider private readonly ClusterEndpoint cluster; private ExecCredentialResponse response; - public ExecTokenProvider(ExternalExecution exec, ClusterEndpoint cluster = null) + public ExecTokenProvider(ExternalExecution exec) + : this(exec, null) + { + } + + public ExecTokenProvider(ExternalExecution exec, ClusterEndpoint cluster) { this.exec = exec; this.cluster = cluster; diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index a2b3ecd8b..a860c147c 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -313,9 +313,10 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { - var caPath = GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority); - CaData = Convert.ToBase64String(File.ReadAllBytes(caPath)); - SslCaCerts = CertUtils.LoadPemFileCert(caPath); + var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); + CaData = Convert.ToBase64String(caBytes); + var pemText = Encoding.UTF8.GetString(caBytes); + SslCaCerts = CertUtils.LoadFromPemText(pemText); } } } @@ -517,7 +518,12 @@ internal static JsonNode ToExecClusterInfo(ClusterEndpoint cluster) return node; } - public static Process CreateRunnableExternalProcess(ExternalExecution config, EventHandler captureStdError = null, ClusterEndpoint cluster = null) + 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) { @@ -527,7 +533,11 @@ public static Process CreateRunnableExternalProcess(ExternalExecution config, Ev var spec = new JsonObject { ["interactive"] = Environment.UserInteractive }; if (config.ProvideClusterInfo) { - spec["cluster"] = ToExecClusterInfo(cluster); + var clusterNode = ToExecClusterInfo(cluster); + if (clusterNode != null) + { + spec["cluster"] = clusterNode; + } } var execInfo = new JsonObject @@ -582,7 +592,12 @@ 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, ClusterEndpoint cluster = null) + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + { + return ExecuteExternalCommand(config, null); + } + + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ClusterEndpoint cluster) { if (config == null) { diff --git a/tests/KubernetesClient.Tests/ExternalExecutionTests.cs b/tests/KubernetesClient.Tests/ExternalExecutionTests.cs index b49d3d157..5ffe451cf 100644 --- a/tests/KubernetesClient.Tests/ExternalExecutionTests.cs +++ b/tests/KubernetesClient.Tests/ExternalExecutionTests.cs @@ -99,6 +99,7 @@ public void CreateRunnableExternalProcessIncludesClusterWhenProvideClusterInfoIs Command = "my-credential-plugin", ProvideClusterInfo = true, }, + captureStdError: null, cluster: cluster); var json = JsonNode.Parse(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); @@ -121,6 +122,7 @@ public void CreateRunnableExternalProcessOmitsClusterWhenProvideClusterInfoIsFal Command = "my-credential-plugin", ProvideClusterInfo = false, }, + captureStdError: null, cluster: cluster); var json = JsonNode.Parse(actual.StartInfo.EnvironmentVariables["KUBERNETES_EXEC_INFO"]); @@ -169,6 +171,23 @@ public void ToExecClusterInfoEmitsInsecureSkipTlsVerifyWhenTrue() 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 From 615741e0c74aca218d75c74dca105246abc3c283 Mon Sep 17 00:00:00 2001 From: Atharva Mutsaddi Date: Wed, 17 Jun 2026 14:46:35 +0530 Subject: [PATCH 3/6] rename CaData to CertificateAuthorityData --- .../KubernetesClientConfiguration.ConfigFile.cs | 10 +++++----- .../KubernetesClientConfiguration.ConfigFile.cs | 8 ++++---- src/KubernetesClient/KubernetesClientConfiguration.cs | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index f9a887fd3..f44715a90 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -307,20 +307,20 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext { if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthorityData)) { - CaData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + CertificateAuthorityData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; #if NET9_0_OR_GREATER - SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(Convert.FromBase64String(CaData))); + 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(CaData), nullPassword)); + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(Convert.FromBase64String(CertificateAuthorityData), nullPassword)); #endif } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); - CaData = Convert.ToBase64String(caBytes); + CertificateAuthorityData = Convert.ToBase64String(caBytes); #if NET9_0_OR_GREATER SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(caBytes)); #else @@ -424,7 +424,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) Server = this.Host, SkipTlsVerify = this.SkipTlsVerify, TlsServerName = this.TlsServerName, - CertificateAuthorityData = this.CaData, + CertificateAuthorityData = this.CertificateAuthorityData, }; } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index a860c147c..7d3919e7c 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -307,14 +307,14 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext { if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthorityData)) { - CaData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; - var pemText = Encoding.UTF8.GetString(Convert.FromBase64String(CaData)); + CertificateAuthorityData = clusterDetails.ClusterEndpoint.CertificateAuthorityData; + var pemText = Encoding.UTF8.GetString(Convert.FromBase64String(CertificateAuthorityData)); SslCaCerts = CertUtils.LoadFromPemText(pemText); } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); - CaData = Convert.ToBase64String(caBytes); + CertificateAuthorityData = Convert.ToBase64String(caBytes); var pemText = Encoding.UTF8.GetString(caBytes); SslCaCerts = CertUtils.LoadFromPemText(pemText); } @@ -441,7 +441,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) Server = this.Host, SkipTlsVerify = this.SkipTlsVerify, TlsServerName = this.TlsServerName, - CertificateAuthorityData = this.CaData, + CertificateAuthorityData = this.CertificateAuthorityData, Extensions = rawCluster?.Extensions, }; } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.cs b/src/KubernetesClient/KubernetesClientConfiguration.cs index 99a01fccc..780230175 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.cs @@ -65,7 +65,7 @@ public partial class KubernetesClientConfiguration /// Gets the base64-encoded PEM certificate authority data, resolved from either /// inline data or file path during cluster configuration. /// - public string CaData { get; set; } + public string CertificateAuthorityData { get; set; } /// /// Gets or sets the HTTP user agent. From cce84f7b423bc9d8d27533484c7884907b6480c7 Mon Sep 17 00:00:00 2001 From: Atharva Mutsaddi Date: Wed, 17 Jun 2026 15:30:07 +0530 Subject: [PATCH 4/6] make caByte reads PEM Safe --- .../KubernetesClientConfiguration.ConfigFile.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index f44715a90..66f619621 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; using System.Text.Json.Nodes; namespace k8s @@ -321,12 +322,8 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext { var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); CertificateAuthorityData = Convert.ToBase64String(caBytes); -#if NET9_0_OR_GREATER - SslCaCerts = new X509Certificate2Collection(X509CertificateLoader.LoadCertificate(caBytes)); -#else - string nullPassword = null; - SslCaCerts = new X509Certificate2Collection(new X509Certificate2(caBytes, nullPassword)); -#endif + SslCaCerts = new X509Certificate2Collection(); + SslCaCerts.ImportFromPem(Encoding.UTF8.GetString(caBytes)); } } } From df9c6439d9e2025ead56e915cf6a6ffa8c4c16ff Mon Sep 17 00:00:00 2001 From: Atharva Mutsaddi Date: Thu, 18 Jun 2026 11:10:45 +0530 Subject: [PATCH 5/6] Add ExecExtensionName constant and improve certificate loading logic --- .../KubernetesClientConfiguration.ConfigFile.cs | 16 +++++++++++----- .../KubernetesClientConfiguration.ConfigFile.cs | 6 ++++-- .../ExternalExecutionTests.cs | 4 ++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index 66f619621..a5fab6a9f 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -5,7 +5,6 @@ using System.Net; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; -using System.Text; using System.Text.Json.Nodes; namespace k8s @@ -320,10 +319,17 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext } else if (!string.IsNullOrEmpty(clusterDetails.ClusterEndpoint.CertificateAuthority)) { - var caBytes = File.ReadAllBytes(GetFullPath(k8SConfig, clusterDetails.ClusterEndpoint.CertificateAuthority)); - CertificateAuthorityData = Convert.ToBase64String(caBytes); - SslCaCerts = new X509Certificate2Collection(); - SslCaCerts.ImportFromPem(Encoding.UTF8.GetString(caBytes)); + 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(caPath)); +#else + SslCaCerts = new X509Certificate2Collection(new X509Certificate2(caPath)); +#endif } } } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index 7d3919e7c..b02844089 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -11,6 +11,8 @@ namespace k8s { public partial class KubernetesClientConfiguration { + internal const string ExecExtensionName = "client.authentication.k8s.io/exec"; + /// /// kubeconfig Default Location /// @@ -505,13 +507,13 @@ internal static JsonNode ToExecClusterInfo(ClusterEndpoint cluster) } var execExtension = cluster.Extensions? - .FirstOrDefault(e => e.Name == "client.authentication.k8s.io/exec"); + .FirstOrDefault(e => e.Name == ExecExtensionName); if (execExtension != null) { object extConfig = execExtension.Extension; if (extConfig != null) { - node["config"] = JsonNode.Parse(JsonSerializer.Serialize(extConfig)); + node["config"] = JsonSerializer.SerializeToNode(extConfig); } } diff --git a/tests/KubernetesClient.Tests/ExternalExecutionTests.cs b/tests/KubernetesClient.Tests/ExternalExecutionTests.cs index 5ffe451cf..ea6b7a3b6 100644 --- a/tests/KubernetesClient.Tests/ExternalExecutionTests.cs +++ b/tests/KubernetesClient.Tests/ExternalExecutionTests.cs @@ -42,7 +42,7 @@ public void ToExecClusterInfoMapsFieldsCorrectly() { new NamedExtension { - Name = "client.authentication.k8s.io/exec", + Name = KubernetesClientConfiguration.ExecExtensionName, Extension = new Dictionary { { "audience", "06e3fbd18de8" } }, }, }, @@ -139,7 +139,7 @@ public void ToExecClusterInfoHandlesNestedExtensions() { new NamedExtension { - Name = "client.authentication.k8s.io/exec", + Name = KubernetesClientConfiguration.ExecExtensionName, Extension = BuildNestedExtension(), }, }, From 4db82895837c42f06979da34f8784be7a193e4ab Mon Sep 17 00:00:00 2001 From: Atharva Mutsaddi Date: Thu, 18 Jun 2026 18:25:11 +0530 Subject: [PATCH 6/6] Reset CA data so it always reflects the cluster currently being resolved --- .../KubernetesClientConfiguration.ConfigFile.cs | 4 ++++ .../KubernetesClientConfiguration.ConfigFile.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index a5fab6a9f..a9345b215 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -278,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)"); diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index b02844089..d1b42686a 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -280,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)");