Skip to content

Commit b07e78a

Browse files
authored
Out-of-tree client authentication providers (UserCredentials exec option) for asp.net core applications (#359)
* Adding the user credentials exec abillity new file: src/KubernetesClient/KubeConfigModels/ExecCredentialResponse.cs new file: src/KubernetesClient/KubeConfigModels/ExternalExecution.cs modified: src/KubernetesClient/KubeConfigModels/UserCredentials.cs modified: src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs * Fixed a few issues with the process spawning and some null references issues * Removed unused import that caused the build to fail (Mail) * Added preprocessor directive that will disable out-of-tree client authentication in case it is not a asp.net core app * Added tests to the new external execution (out-of-tree client authentication) extension * Trying to fix failing tests that fail apparently due to the preprocessor symbol * Trying to fix failing macos tests * Added the -n (do not output trailing newline) and the -E options to the echo command in OSX * initializing arguments variable * Changes according to tg123 comments Changed OSX testing command to printf to try and solve the JSON parsing errors * Added missing references * Environment.UserInteractive and Process applies to .NET Standard >= 2.0 according to Microsoft documentation
1 parent e11cc58 commit b07e78a

File tree

5 files changed

+294
-50
lines changed

5 files changed

+294
-50
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Collections.Generic;
2+
using Newtonsoft.Json;
3+
4+
namespace k8s.KubeConfigModels
5+
{
6+
public class ExecCredentialResponse
7+
{
8+
[JsonProperty("apiVersion")]
9+
public string ApiVersion { get; set; }
10+
[JsonProperty("kind")]
11+
public string Kind { get; set; }
12+
[JsonProperty("status")]
13+
public IDictionary<string, string> Status { get; set; }
14+
}
15+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System.Collections.Generic;
2+
using YamlDotNet.Serialization;
3+
4+
namespace k8s.KubeConfigModels
5+
{
6+
public class ExternalExecution
7+
{
8+
[YamlMember(Alias = "apiVersion")]
9+
public string ApiVersion { get; set; }
10+
/// <summary>
11+
/// The command to execute. Required.
12+
/// </summary>
13+
[YamlMember(Alias = "command")]
14+
public string Command { get; set; }
15+
/// <summary>
16+
/// Environment variables to set when executing the plugin. Optional.
17+
/// </summary>
18+
[YamlMember(Alias = "env")]
19+
public IDictionary<string, string> EnvironmentVariables { get; set; }
20+
/// <summary>
21+
/// Arguments to pass when executing the plugin. Optional.
22+
/// </summary>
23+
[YamlMember(Alias = "args")]
24+
public IList<string> Arguments { get; set; }
25+
}
26+
}

src/KubernetesClient/KubeConfigModels/UserCredentials.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,11 @@ public class UserCredentials
8080
/// </summary>
8181
[YamlMember(Alias = "extensions")]
8282
public IDictionary<string, dynamic> Extensions { get; set; }
83+
84+
/// <summary>
85+
/// Gets or sets external command and its arguments to receive user credentials
86+
/// </summary>
87+
[YamlMember(Alias = "exec")]
88+
public ExternalExecution ExternalExecution { get; set; }
8389
}
8490
}

src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs

Lines changed: 151 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
using System;
2+
#if NETSTANDARD2_0
3+
using Newtonsoft.Json;
24
using System.Collections.Generic;
5+
using System.Diagnostics;
6+
#endif
37
using System.IO;
48
using System.Linq;
59
using System.Runtime.InteropServices;
@@ -8,6 +12,7 @@
812
using k8s.Exceptions;
913
using k8s.KubeConfigModels;
1014

15+
1116
namespace k8s
1217
{
1318
public partial class KubernetesClientConfiguration
@@ -28,15 +33,19 @@ public partial class KubernetesClientConfiguration
2833
/// <summary>
2934
/// Initializes a new instance of the <see cref="KubernetesClientConfiguration" /> from config file
3035
/// </summary>
31-
public static KubernetesClientConfiguration BuildDefaultConfig() {
36+
public static KubernetesClientConfiguration BuildDefaultConfig()
37+
{
3238
var kubeconfig = Environment.GetEnvironmentVariable("KUBECONFIG");
33-
if (kubeconfig != null) {
39+
if (kubeconfig != null)
40+
{
3441
return BuildConfigFromConfigFile(kubeconfigPath: kubeconfig);
3542
}
36-
if (File.Exists(KubeConfigDefaultLocation)) {
43+
if (File.Exists(KubeConfigDefaultLocation))
44+
{
3745
return BuildConfigFromConfigFile(kubeconfigPath: KubeConfigDefaultLocation);
3846
}
39-
if (IsInCluster()) {
47+
if (IsInCluster())
48+
{
4049
return InClusterConfig();
4150
}
4251
var config = new KubernetesClientConfiguration();
@@ -150,7 +159,7 @@ private static KubernetesClientConfiguration GetKubernetesClientConfiguration(st
150159
var k8SConfiguration = new KubernetesClientConfiguration();
151160

152161
currentContext = currentContext ?? k8SConfig.CurrentContext;
153-
// only init context if context if set
162+
// only init context if context is set
154163
if (currentContext != null)
155164
{
156165
k8SConfiguration.InitializeContext(k8SConfig, currentContext);
@@ -214,7 +223,7 @@ private void SetClusterDetails(K8SConfiguration k8SConfig, Context activeContext
214223
Host = clusterDetails.ClusterEndpoint.Server;
215224
SkipTlsVerify = clusterDetails.ClusterEndpoint.SkipTlsVerify;
216225

217-
if(!Uri.TryCreate(Host, UriKind.Absolute, out Uri uri))
226+
if (!Uri.TryCreate(Host, UriKind.Absolute, out Uri uri))
218227
{
219228
throw new KubeConfigException($"Bad server host URL `{Host}` (cannot be parsed)");
220229
}
@@ -294,65 +303,81 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext)
294303
switch (userDetails.UserCredentials.AuthProvider.Name)
295304
{
296305
case "azure":
297-
{
298-
var config = userDetails.UserCredentials.AuthProvider.Config;
299-
if (config.ContainsKey("expires-on"))
300306
{
301-
var expiresOn = Int32.Parse(config["expires-on"]);
302-
DateTimeOffset expires;
303-
#if NET452
307+
var config = userDetails.UserCredentials.AuthProvider.Config;
308+
if (config.ContainsKey("expires-on"))
309+
{
310+
var expiresOn = Int32.Parse(config["expires-on"]);
311+
DateTimeOffset expires;
312+
#if NET452
304313
var epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
305314
expires = epoch.AddSeconds(expiresOn);
306-
#else
307-
expires = DateTimeOffset.FromUnixTimeSeconds(expiresOn);
308-
#endif
315+
#else
316+
expires = DateTimeOffset.FromUnixTimeSeconds(expiresOn);
317+
#endif
309318

310-
if (DateTimeOffset.Compare(expires
311-
, DateTimeOffset.Now)
312-
<= 0)
313-
{
314-
var tenantId = config["tenant-id"];
315-
var clientId = config["client-id"];
316-
var apiServerId = config["apiserver-id"];
317-
var refresh = config["refresh-token"];
318-
var newToken = RenewAzureToken(tenantId
319-
, clientId
320-
, apiServerId
321-
, refresh);
322-
config["access-token"] = newToken;
319+
if (DateTimeOffset.Compare(expires
320+
, DateTimeOffset.Now)
321+
<= 0)
322+
{
323+
var tenantId = config["tenant-id"];
324+
var clientId = config["client-id"];
325+
var apiServerId = config["apiserver-id"];
326+
var refresh = config["refresh-token"];
327+
var newToken = RenewAzureToken(tenantId
328+
, clientId
329+
, apiServerId
330+
, refresh);
331+
config["access-token"] = newToken;
332+
}
323333
}
324-
}
325334

326-
AccessToken = config["access-token"];
327-
userCredentialsFound = true;
328-
break;
329-
}
335+
AccessToken = config["access-token"];
336+
userCredentialsFound = true;
337+
break;
338+
}
330339
case "gcp":
331-
{
332-
var config = userDetails.UserCredentials.AuthProvider.Config;
333-
const string keyExpire = "expiry";
334-
if (config.ContainsKey(keyExpire))
335340
{
336-
if (DateTimeOffset.TryParse(config[keyExpire]
337-
, out DateTimeOffset expires))
341+
var config = userDetails.UserCredentials.AuthProvider.Config;
342+
const string keyExpire = "expiry";
343+
if (config.ContainsKey(keyExpire))
338344
{
339-
if (DateTimeOffset.Compare(expires
340-
, DateTimeOffset.Now)
341-
<= 0)
345+
if (DateTimeOffset.TryParse(config[keyExpire]
346+
, out DateTimeOffset expires))
342347
{
343-
throw new KubeConfigException("Refresh not supported.");
348+
if (DateTimeOffset.Compare(expires
349+
, DateTimeOffset.Now)
350+
<= 0)
351+
{
352+
throw new KubeConfigException("Refresh not supported.");
353+
}
344354
}
345355
}
346-
}
347356

348-
AccessToken = config["access-token"];
349-
userCredentialsFound = true;
350-
break;
351-
}
357+
AccessToken = config["access-token"];
358+
userCredentialsFound = true;
359+
break;
360+
}
352361
}
353362
}
354363
}
355364

365+
#if NETSTANDARD2_0
366+
if (userDetails.UserCredentials.ExternalExecution != null)
367+
{
368+
if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.Command))
369+
throw new KubeConfigException(
370+
"External command execution to receive user credentials must include a command to execute");
371+
if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.ApiVersion))
372+
throw new KubeConfigException("External command execution missing ApiVersion key");
373+
374+
var token = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution);
375+
AccessToken = token;
376+
377+
userCredentialsFound = true;
378+
}
379+
#endif
380+
356381
if (!userCredentialsFound)
357382
{
358383
throw new KubeConfigException(
@@ -365,6 +390,84 @@ public static string RenewAzureToken(string tenantId, string clientId, string ap
365390
throw new KubeConfigException("Refresh not supported.");
366391
}
367392

393+
#if NETSTANDARD2_0
394+
/// <summary>
395+
/// Implementation of the proposal for out-of-tree client
396+
/// authentication providers as described here --
397+
/// https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md
398+
/// Took inspiration from python exec_provider.py --
399+
/// https://github.com/kubernetes-client/python-base/blob/master/config/exec_provider.py
400+
/// </summary>
401+
/// <param name="config">The external command execution configuration</param>
402+
/// <returns>The token received from the external commmand execution</returns>
403+
public static string ExecuteExternalCommand(ExternalExecution config)
404+
{
405+
var execInfo = new Dictionary<string, dynamic>
406+
{
407+
{"apiVersion", config.ApiVersion},
408+
{"kind", "ExecCredentials"},
409+
{"spec", new Dictionary<string, bool>
410+
{
411+
{"interactive", Environment.UserInteractive}
412+
}}
413+
};
414+
415+
var process = new Process();
416+
417+
process.StartInfo.Environment.Add("KUBERNETES_EXEC_INFO",
418+
JsonConvert.SerializeObject(execInfo));
419+
420+
if (config.EnvironmentVariables != null)
421+
foreach (var configEnvironmentVariableKey in config.EnvironmentVariables.Keys)
422+
process.StartInfo.Environment.Add(key: configEnvironmentVariableKey,
423+
value: config.EnvironmentVariables[configEnvironmentVariableKey]);
424+
425+
process.StartInfo.FileName = config.Command;
426+
if (config.Arguments != null)
427+
process.StartInfo.Arguments = string.Join(" ", config.Arguments);
428+
process.StartInfo.RedirectStandardOutput = true;
429+
process.StartInfo.RedirectStandardError = true;
430+
process.StartInfo.UseShellExecute = false;
431+
432+
try
433+
{
434+
process.Start();
435+
}
436+
catch (Exception ex)
437+
{
438+
throw new KubeConfigException($"external exec failed due to: {ex.Message}");
439+
}
440+
441+
var stdout = process.StandardOutput.ReadToEnd();
442+
var stderr = process.StandardOutput.ReadToEnd();
443+
if (string.IsNullOrWhiteSpace(stderr) == false)
444+
throw new KubeConfigException($"external exec failed due to: {stderr}");
445+
446+
// Wait for a maximum of 5 seconds, if a response takes longer probably something went wrong...
447+
process.WaitForExit(5);
448+
449+
try
450+
{
451+
var responseObject = JsonConvert.DeserializeObject<ExecCredentialResponse>(stdout);
452+
if (responseObject == null || responseObject.ApiVersion != config.ApiVersion)
453+
throw new KubeConfigException(
454+
$"external exec failed because api version {responseObject.ApiVersion} does not match {config.ApiVersion}");
455+
return responseObject.Status["token"];
456+
}
457+
catch (JsonSerializationException ex)
458+
{
459+
throw new KubeConfigException($"external exec failed due to failed deserialization process: {ex}");
460+
}
461+
catch (Exception ex)
462+
{
463+
throw new KubeConfigException($"external exec failed due to uncaught exception: {ex}");
464+
}
465+
466+
467+
468+
}
469+
#endif
470+
368471
/// <summary>
369472
/// Loads entire Kube Config from default or explicit file path
370473
/// </summary>

0 commit comments

Comments
 (0)