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
This commit is contained in:
Kubernetes Prow Robot
2020-03-05 09:12:38 -08:00
committed by GitHub
parent e11cc58e56
commit b07e78afa4
5 changed files with 296 additions and 52 deletions

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace k8s.KubeConfigModels
{
public class ExecCredentialResponse
{
[JsonProperty("apiVersion")]
public string ApiVersion { get; set; }
[JsonProperty("kind")]
public string Kind { get; set; }
[JsonProperty("status")]
public IDictionary<string, string> Status { get; set; }
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using YamlDotNet.Serialization;
namespace k8s.KubeConfigModels
{
public class ExternalExecution
{
[YamlMember(Alias = "apiVersion")]
public string ApiVersion { get; set; }
/// <summary>
/// The command to execute. Required.
/// </summary>
[YamlMember(Alias = "command")]
public string Command { get; set; }
/// <summary>
/// Environment variables to set when executing the plugin. Optional.
/// </summary>
[YamlMember(Alias = "env")]
public IDictionary<string, string> EnvironmentVariables { get; set; }
/// <summary>
/// Arguments to pass when executing the plugin. Optional.
/// </summary>
[YamlMember(Alias = "args")]
public IList<string> Arguments { get; set; }
}
}

View File

@@ -80,5 +80,11 @@ namespace k8s.KubeConfigModels
/// </summary>
[YamlMember(Alias = "extensions")]
public IDictionary<string, dynamic> Extensions { get; set; }
/// <summary>
/// Gets or sets external command and its arguments to receive user credentials
/// </summary>
[YamlMember(Alias = "exec")]
public ExternalExecution ExternalExecution { get; set; }
}
}

View File

@@ -1,5 +1,9 @@
using System;
#if NETSTANDARD2_0
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Diagnostics;
#endif
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
@@ -8,6 +12,7 @@ using System.Threading.Tasks;
using k8s.Exceptions;
using k8s.KubeConfigModels;
namespace k8s
{
public partial class KubernetesClientConfiguration
@@ -28,15 +33,19 @@ namespace k8s
/// <summary>
/// Initializes a new instance of the <see cref="KubernetesClientConfiguration" /> from config file
/// </summary>
public static KubernetesClientConfiguration BuildDefaultConfig() {
public static KubernetesClientConfiguration BuildDefaultConfig()
{
var kubeconfig = Environment.GetEnvironmentVariable("KUBECONFIG");
if (kubeconfig != null) {
if (kubeconfig != null)
{
return BuildConfigFromConfigFile(kubeconfigPath: kubeconfig);
}
if (File.Exists(KubeConfigDefaultLocation)) {
if (File.Exists(KubeConfigDefaultLocation))
{
return BuildConfigFromConfigFile(kubeconfigPath: KubeConfigDefaultLocation);
}
if (IsInCluster()) {
if (IsInCluster())
{
return InClusterConfig();
}
var config = new KubernetesClientConfiguration();
@@ -150,7 +159,7 @@ namespace k8s
var k8SConfiguration = new KubernetesClientConfiguration();
currentContext = currentContext ?? k8SConfig.CurrentContext;
// only init context if context if set
// only init context if context is set
if (currentContext != null)
{
k8SConfiguration.InitializeContext(k8SConfig, currentContext);
@@ -214,7 +223,7 @@ namespace k8s
Host = clusterDetails.ClusterEndpoint.Server;
SkipTlsVerify = clusterDetails.ClusterEndpoint.SkipTlsVerify;
if(!Uri.TryCreate(Host, UriKind.Absolute, out Uri uri))
if (!Uri.TryCreate(Host, UriKind.Absolute, out Uri uri))
{
throw new KubeConfigException($"Bad server host URL `{Host}` (cannot be parsed)");
}
@@ -294,65 +303,81 @@ namespace k8s
switch (userDetails.UserCredentials.AuthProvider.Name)
{
case "azure":
{
var config = userDetails.UserCredentials.AuthProvider.Config;
if (config.ContainsKey("expires-on"))
{
var expiresOn = Int32.Parse(config["expires-on"]);
DateTimeOffset expires;
#if NET452
var config = userDetails.UserCredentials.AuthProvider.Config;
if (config.ContainsKey("expires-on"))
{
var expiresOn = Int32.Parse(config["expires-on"]);
DateTimeOffset expires;
#if NET452
var epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
expires = epoch.AddSeconds(expiresOn);
#else
expires = DateTimeOffset.FromUnixTimeSeconds(expiresOn);
#endif
#else
expires = DateTimeOffset.FromUnixTimeSeconds(expiresOn);
#endif
if (DateTimeOffset.Compare(expires
, DateTimeOffset.Now)
<= 0)
{
var tenantId = config["tenant-id"];
var clientId = config["client-id"];
var apiServerId = config["apiserver-id"];
var refresh = config["refresh-token"];
var newToken = RenewAzureToken(tenantId
, clientId
, apiServerId
, refresh);
config["access-token"] = newToken;
}
}
AccessToken = config["access-token"];
userCredentialsFound = true;
break;
}
case "gcp":
{
var config = userDetails.UserCredentials.AuthProvider.Config;
const string keyExpire = "expiry";
if (config.ContainsKey(keyExpire))
{
if (DateTimeOffset.TryParse(config[keyExpire]
, out DateTimeOffset expires))
{
if (DateTimeOffset.Compare(expires
, DateTimeOffset.Now)
<= 0)
{
throw new KubeConfigException("Refresh not supported.");
var tenantId = config["tenant-id"];
var clientId = config["client-id"];
var apiServerId = config["apiserver-id"];
var refresh = config["refresh-token"];
var newToken = RenewAzureToken(tenantId
, clientId
, apiServerId
, refresh);
config["access-token"] = newToken;
}
}
}
AccessToken = config["access-token"];
userCredentialsFound = true;
break;
}
AccessToken = config["access-token"];
userCredentialsFound = true;
break;
}
case "gcp":
{
var config = userDetails.UserCredentials.AuthProvider.Config;
const string keyExpire = "expiry";
if (config.ContainsKey(keyExpire))
{
if (DateTimeOffset.TryParse(config[keyExpire]
, out DateTimeOffset expires))
{
if (DateTimeOffset.Compare(expires
, DateTimeOffset.Now)
<= 0)
{
throw new KubeConfigException("Refresh not supported.");
}
}
}
AccessToken = config["access-token"];
userCredentialsFound = true;
break;
}
}
}
}
#if NETSTANDARD2_0
if (userDetails.UserCredentials.ExternalExecution != null)
{
if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.Command))
throw new KubeConfigException(
"External command execution to receive user credentials must include a command to execute");
if (string.IsNullOrWhiteSpace(userDetails.UserCredentials.ExternalExecution.ApiVersion))
throw new KubeConfigException("External command execution missing ApiVersion key");
var token = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution);
AccessToken = token;
userCredentialsFound = true;
}
#endif
if (!userCredentialsFound)
{
throw new KubeConfigException(
@@ -365,6 +390,84 @@ namespace k8s
throw new KubeConfigException("Refresh not supported.");
}
#if NETSTANDARD2_0
/// <summary>
/// Implementation of the proposal for out-of-tree client
/// authentication providers as described here --
/// https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md
/// Took inspiration from python exec_provider.py --
/// https://github.com/kubernetes-client/python-base/blob/master/config/exec_provider.py
/// </summary>
/// <param name="config">The external command execution configuration</param>
/// <returns>The token received from the external commmand execution</returns>
public static string ExecuteExternalCommand(ExternalExecution config)
{
var execInfo = new Dictionary<string, dynamic>
{
{"apiVersion", config.ApiVersion},
{"kind", "ExecCredentials"},
{"spec", new Dictionary<string, bool>
{
{"interactive", Environment.UserInteractive}
}}
};
var process = new Process();
process.StartInfo.Environment.Add("KUBERNETES_EXEC_INFO",
JsonConvert.SerializeObject(execInfo));
if (config.EnvironmentVariables != null)
foreach (var configEnvironmentVariableKey in config.EnvironmentVariables.Keys)
process.StartInfo.Environment.Add(key: configEnvironmentVariableKey,
value: config.EnvironmentVariables[configEnvironmentVariableKey]);
process.StartInfo.FileName = config.Command;
if (config.Arguments != null)
process.StartInfo.Arguments = string.Join(" ", config.Arguments);
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
try
{
process.Start();
}
catch (Exception ex)
{
throw new KubeConfigException($"external exec failed due to: {ex.Message}");
}
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardOutput.ReadToEnd();
if (string.IsNullOrWhiteSpace(stderr) == false)
throw new KubeConfigException($"external exec failed due to: {stderr}");
// Wait for a maximum of 5 seconds, if a response takes longer probably something went wrong...
process.WaitForExit(5);
try
{
var responseObject = JsonConvert.DeserializeObject<ExecCredentialResponse>(stdout);
if (responseObject == null || responseObject.ApiVersion != config.ApiVersion)
throw new KubeConfigException(
$"external exec failed because api version {responseObject.ApiVersion} does not match {config.ApiVersion}");
return responseObject.Status["token"];
}
catch (JsonSerializationException ex)
{
throw new KubeConfigException($"external exec failed due to failed deserialization process: {ex}");
}
catch (Exception ex)
{
throw new KubeConfigException($"external exec failed due to uncaught exception: {ex}");
}
}
#endif
/// <summary>
/// Loads entire Kube Config from default or explicit file path
/// </summary>

View File

@@ -9,6 +9,7 @@ using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using k8s.KubeConfigModels;
using k8s.Models;
using k8s.Tests.Mock;
using Microsoft.AspNetCore.Hosting;
@@ -168,8 +169,7 @@ namespace k8s.Tests
}
}
#if NETCOREAPP2_1 // The functionality under test, here, is dependent on managed HTTP / WebSocket functionality in .NET Core 2.1 or newer.
#if NETCOREAPP2_1 // The functionality under test, here, is dependent on managed HTTP / WebSocket in .NET Core 2.1 or newer.
[Fact]
public void Cert()
{
@@ -280,6 +280,47 @@ namespace k8s.Tests
#endif // NETCOREAPP2_1
#if NETSTANDARD2_0
[Fact]
public void ExternalToken()
{
const string token = "testingtoken";
const string name = "testing_irrelevant";
using (var server = new MockKubeApiServer(testOutput, cxt =>
{
var header = cxt.Request.Headers["Authorization"].FirstOrDefault();
var expect = new AuthenticationHeaderValue("Bearer", token).ToString();
if (header != expect)
{
cxt.Response.StatusCode = (int) HttpStatusCode.Unauthorized;
return Task.FromResult(false);
}
return Task.FromResult(true);
}))
{
{
var kubernetesConfig = GetK8SConfiguration(server.Uri.ToString(), token, name);
var clientConfig = KubernetesClientConfiguration.BuildConfigFromConfigObject(kubernetesConfig, name);
var client = new Kubernetes(clientConfig);
var listTask = ExecuteListPods(client);
Assert.True(listTask.Response.IsSuccessStatusCode);
Assert.Equal(1, listTask.Body.Items.Count);
}
{
var kubernetesConfig = GetK8SConfiguration(server.Uri.ToString(), "wrong token", name);
var clientConfig = KubernetesClientConfiguration.BuildConfigFromConfigObject(kubernetesConfig, name);
var client = new Kubernetes(clientConfig);
var listTask = ExecuteListPods(client);
Assert.Equal(HttpStatusCode.Unauthorized, listTask.Response.StatusCode);
}
}
}
#endif // NETSTANDARD2_0
[Fact]
public void Token()
{
@@ -371,5 +412,58 @@ namespace k8s.Tests
return certificate;
}
private K8SConfiguration GetK8SConfiguration(string serverUri, string token, string name)
{
const string username = "testinguser";
var contexts = new List<Context>
{
new Context {Name = name, ContextDetails = new ContextDetails {Cluster = name, User = username}}
};
var responseJson = $"{{\"apiVersion\": \"testingversion\", \"status\": {{\"token\": \"{token}\"}}}}";
{
var clusters = new List<Cluster>
{
new Cluster
{
Name = name,
ClusterEndpoint = new ClusterEndpoint {SkipTlsVerify = true, Server = serverUri}
}
};
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "echo";
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
command = "printf";
var arguments = new string[] { };
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
arguments = ($"/c echo {responseJson}").Split(" ");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
arguments = new[] {responseJson};
var users = new List<User>
{
new User
{
Name = username,
UserCredentials = new UserCredentials
{
ExternalExecution = new ExternalExecution
{
ApiVersion = "testingversion",
Command = command,
Arguments = arguments.ToList()
}
}
}
};
var kubernetesConfig = new K8SConfiguration {Clusters = clusters, Users = users, Contexts = contexts};
return kubernetesConfig;
}
}
}
}