client-go: add an exec-based client auth provider

Kubernetes-commit: 6463e9efd9ba552e60d2555a3e6526ef90196473
This commit is contained in:
Eric Chiang 2018-02-07 15:43:12 -08:00 committed by Kubernetes Publisher
parent d902e7da4b
commit 19c591bac2
20 changed files with 1272 additions and 4 deletions

View File

@ -0,0 +1,19 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
// +k8s:deepcopy-gen=package
// +groupName=client.authentication.k8s.io
package clientauthentication // import "k8s.io/client-go/pkg/apis/clientauthentication"

View File

@ -0,0 +1,43 @@
/*
Copyright 2017 The Kubernetes Authors.
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.
*/
// Package install installs the experimental API group, making it available as
// an option to all of the API encoding/decoding machinery.
package install
import (
"k8s.io/apimachinery/pkg/apimachinery/announced"
"k8s.io/apimachinery/pkg/apimachinery/registered"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/pkg/apis/clientauthentication"
"k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1"
)
// Install registers the API group and adds types to a scheme
func Install(groupFactoryRegistry announced.APIGroupFactoryRegistry, registry *registered.APIRegistrationManager, scheme *runtime.Scheme) {
if err := announced.NewGroupMetaFactory(
&announced.GroupMetaFactoryArgs{
GroupName: clientauthentication.GroupName,
VersionPreferenceOrder: []string{v1alpha1.SchemeGroupVersion.Version},
AddInternalObjectsToScheme: clientauthentication.AddToScheme,
},
announced.VersionToSchemeFunc{
v1alpha1.SchemeGroupVersion.Version: v1alpha1.AddToScheme,
},
).Announce(groupFactoryRegistry).RegisterAndEnable(registry, scheme); err != nil {
panic(err)
}
}

View File

@ -0,0 +1,50 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
package clientauthentication
import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// GroupName is the group name use in this package
const GroupName = "client.authentication.k8s.io"
// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal}
// Kind takes an unqualified kind and returns a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
return SchemeGroupVersion.WithKind(kind).GroupKind()
}
// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}
var (
SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
AddToScheme = SchemeBuilder.AddToScheme
)
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&ExecCredential{},
)
return nil
}

View File

@ -0,0 +1,70 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
package clientauthentication
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// ExecCredentials is used by exec-based plugins to communicate credentials to
// HTTP transports.
type ExecCredential struct {
metav1.TypeMeta
// Spec holds information passed to the plugin by the transport. This contains
// request and runtime specific information, such as if the session is interactive.
Spec ExecCredentialSpec
// Status is filled in by the plugin and holds the credentials that the transport
// should use to contact the API.
// +optional
Status *ExecCredentialStatus
}
// ExecCredenitalSpec holds request and runtime specific information provided by
// the transport.
type ExecCredentialSpec struct {
// Response is populated when the transport encounters HTTP status codes, such as 401,
// suggesting previous credentials were invalid.
// +optional
Response *Response
// Interactive is true when the transport detects the command is being called from an
// interactive prompt.
// +optional
Interactive bool
}
// ExecCredentialStatus holds credentials for the transport to use.
type ExecCredentialStatus struct {
// ExpirationTimestamp indicates a time when the provided credentials expire.
// +optional
ExpirationTimestamp *metav1.Time
// Token is a bearer token used by the client for request authentication.
Token string
}
// Response defines metadata about a failed request, including HTTP status code and
// response headers.
type Response struct {
// Headers holds HTTP headers returned by the server.
Header map[string][]string
// Code is the HTTP status code returned by the server.
Code int32
}

View File

@ -0,0 +1,23 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
// +k8s:deepcopy-gen=package
// +k8s:conversion-gen=k8s.io/client-go/pkg/apis/clientauthentication
// +k8s:openapi-gen=true
// +k8s:defaulter-gen=TypeMeta
// +groupName=client.authentication.k8s.io
package v1alpha1 // import "k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1"

View File

@ -0,0 +1,55 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// GroupName is the group name use in this package
const GroupName = "client.authentication.k8s.io"
// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"}
// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}
var (
SchemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &SchemeBuilder
AddToScheme = localSchemeBuilder.AddToScheme
)
func init() {
// We only register manually written functions here. The registration of the
// generated functions takes place in the generated files. The separation
// makes the code compile even when the generated files are missing.
localSchemeBuilder.Register(addKnownTypes)
}
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&ExecCredential{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}

View File

@ -0,0 +1,70 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// ExecCredentials is used by exec-based plugins to communicate credentials to
// HTTP transports.
type ExecCredential struct {
metav1.TypeMeta `json:",inline"`
// Spec holds information passed to the plugin by the transport. This contains
// request and runtime specific information, such as if the session is interactive.
Spec ExecCredentialSpec `json:"spec,omitempty"`
// Status is filled in by the plugin and holds the credentials that the transport
// should use to contact the API.
// +optional
Status *ExecCredentialStatus `json:"status,omitempty"`
}
// ExecCredenitalSpec holds request and runtime specific information provided by
// the transport.
type ExecCredentialSpec struct {
// Response is populated when the transport encounters HTTP status codes, such as 401,
// suggesting previous credentials were invalid.
// +optional
Response *Response `json:"response,omitempty"`
// Interactive is true when the transport detects the command is being called from an
// interactive prompt.
// +optional
Interactive bool `json:"interactive,omitempty"`
}
// ExecCredentialStatus holds credentials for the transport to use.
type ExecCredentialStatus struct {
// ExpirationTimestamp indicates a time when the provided credentials expire.
// +optional
ExpirationTimestamp *metav1.Time `json:"expirationTimestamp,omitempty"`
// Token is a bearer token used by the client for request authentication.
Token string `json:"token,omitempty"`
}
// Response defines metadata about a failed request, including HTTP status code and
// response headers.
type Response struct {
// Header holds HTTP headers returned by the server.
Header map[string][]string `json:"header,omitempty"`
// Code is the HTTP status code returned by the server.
Code int32 `json:"code,omitempty"`
}

View File

@ -0,0 +1,280 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
package exec
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/golang/glog"
"golang.org/x/crypto/ssh/terminal"
"k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/client-go/pkg/apis/clientauthentication"
"k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1"
"k8s.io/client-go/tools/clientcmd/api"
)
const execInfoEnv = "KUBERNETES_EXEC_INFO"
var scheme = runtime.NewScheme()
var codecs = serializer.NewCodecFactory(scheme)
func init() {
v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"})
v1alpha1.AddToScheme(scheme)
clientauthentication.AddToScheme(scheme)
}
var (
// Since transports can be constantly re-initialized by programs like kubectl,
// keep a cache of initialized authenticators keyed by a hash of their config.
globalCache = newCache()
// The list of API versions we accept.
apiVersions = map[string]schema.GroupVersion{
v1alpha1.SchemeGroupVersion.String(): v1alpha1.SchemeGroupVersion,
}
)
func newCache() *cache {
return &cache{m: make(map[string]*Authenticator)}
}
func cacheKey(c *api.ExecConfig) string {
return fmt.Sprintf("%#v", c)
}
type cache struct {
mu sync.Mutex
m map[string]*Authenticator
}
func (c *cache) get(s string) (*Authenticator, bool) {
c.mu.Lock()
defer c.mu.Unlock()
a, ok := c.m[s]
return a, ok
}
// put inserts an authenticator into the cache. If an authenticator is already
// associated with the key, the first one is returned instead.
func (c *cache) put(s string, a *Authenticator) *Authenticator {
c.mu.Lock()
defer c.mu.Unlock()
existing, ok := c.m[s]
if ok {
return existing
}
c.m[s] = a
return a
}
// GetAuthenticator returns an exec-based plugin for providing client credentials.
func GetAuthenticator(config *api.ExecConfig) (*Authenticator, error) {
return newAuthenticator(globalCache, config)
}
func newAuthenticator(c *cache, config *api.ExecConfig) (*Authenticator, error) {
key := cacheKey(config)
if a, ok := c.get(key); ok {
return a, nil
}
gv, ok := apiVersions[config.APIVersion]
if !ok {
return nil, fmt.Errorf("exec plugin: invalid apiVersion %q", config.APIVersion)
}
a := &Authenticator{
cmd: config.Command,
args: config.Args,
group: gv,
stdin: os.Stdin,
stderr: os.Stderr,
interactive: terminal.IsTerminal(int(os.Stdout.Fd())),
now: time.Now,
environ: os.Environ,
}
for _, env := range config.Env {
a.env = append(a.env, env.Name+"="+env.Value)
}
return c.put(key, a), nil
}
// Authenticator is a client credential provider that rotates credentials by executing a plugin.
// The plugin input and output are defined by the API group client.authentication.k8s.io.
type Authenticator struct {
// Set by the config
cmd string
args []string
group schema.GroupVersion
env []string
// Stubbable for testing
stdin io.Reader
stderr io.Writer
interactive bool
now func() time.Time
environ func() []string
// Cached results.
//
// The mutex also guards calling the plugin. Since the plugin could be
// interactive we want to make sure it's only called once.
mu sync.Mutex
cachedToken string
exp time.Time
}
// WrapTransport instruments an existing http.RoundTripper with credentials returned
// by the plugin.
func (a *Authenticator) WrapTransport(rt http.RoundTripper) http.RoundTripper {
return &roundTripper{a, rt}
}
type roundTripper struct {
a *Authenticator
base http.RoundTripper
}
func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// If a user has already set credentials, use that. This makes commands like
// "kubectl get --token (token) pods" work.
if req.Header.Get("Authorization") != "" {
return r.base.RoundTrip(req)
}
token, err := r.a.token()
if err != nil {
return nil, fmt.Errorf("getting token: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
res, err := r.base.RoundTrip(req)
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusUnauthorized {
resp := &clientauthentication.Response{
Header: res.Header,
Code: int32(res.StatusCode),
}
if err := r.a.refresh(token, resp); err != nil {
glog.Errorf("refreshing token: %v", err)
}
}
return res, nil
}
func (a *Authenticator) tokenExpired() bool {
if a.exp.IsZero() {
return false
}
return a.now().After(a.exp)
}
func (a *Authenticator) token() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.cachedToken != "" && !a.tokenExpired() {
return a.cachedToken, nil
}
return a.getToken(nil)
}
// refresh executes the plugin to force a rotation of the token.
func (a *Authenticator) refresh(token string, r *clientauthentication.Response) error {
a.mu.Lock()
defer a.mu.Unlock()
if token != a.cachedToken {
// Token already rotated.
return nil
}
_, err := a.getToken(r)
return err
}
// getToken executes the plugin and reads the credentials from stdout. It must be
// called while holding the Authenticator's mutex.
func (a *Authenticator) getToken(r *clientauthentication.Response) (string, error) {
cred := &clientauthentication.ExecCredential{
Spec: clientauthentication.ExecCredentialSpec{
Response: r,
Interactive: a.interactive,
},
}
data, err := runtime.Encode(codecs.LegacyCodec(a.group), cred)
if err != nil {
return "", fmt.Errorf("encode ExecCredentials: %v", err)
}
env := append(a.environ(), a.env...)
env = append(env, fmt.Sprintf("%s=%s", execInfoEnv, data))
stdout := &bytes.Buffer{}
cmd := exec.Command(a.cmd, a.args...)
cmd.Env = env
cmd.Stderr = a.stderr
cmd.Stdout = stdout
if a.interactive {
cmd.Stdin = a.stdin
}
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("exec: %v", err)
}
_, gvk, err := codecs.UniversalDecoder(a.group).Decode(stdout.Bytes(), nil, cred)
if err != nil {
return "", fmt.Errorf("decode stdout: %v", err)
}
if gvk.Group != a.group.Group || gvk.Version != a.group.Version {
return "", fmt.Errorf("exec plugin is configured to use API version %s, plugin returned version %s",
a.group, schema.GroupVersion{Group: gvk.Group, Version: gvk.Version})
}
if cred.Status == nil {
return "", fmt.Errorf("exec plugin didn't return a status field")
}
if cred.Status.Token == "" {
return "", fmt.Errorf("exec plugin didn't return a token")
}
if cred.Status.ExpirationTimestamp != nil {
a.exp = cred.Status.ExpirationTimestamp.Time
} else {
a.exp = time.Time{}
}
a.cachedToken = cred.Status.Token
return a.cachedToken, nil
}

View File

@ -0,0 +1,413 @@
/*
Copyright 2018 The Kubernetes Authors.
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.
*/
package exec
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"k8s.io/client-go/pkg/apis/clientauthentication"
"k8s.io/client-go/tools/clientcmd/api"
)
func TestCacheKey(t *testing.T) {
c1 := &api.ExecConfig{
Command: "foo-bar",
Args: []string{"1", "2"},
Env: []api.ExecEnvVar{
{Name: "3", Value: "4"},
{Name: "5", Value: "6"},
{Name: "7", Value: "8"},
},
APIVersion: "client.authentication.k8s.io/v1alpha1",
}
c2 := &api.ExecConfig{
Command: "foo-bar",
Args: []string{"1", "2"},
Env: []api.ExecEnvVar{
{Name: "3", Value: "4"},
{Name: "5", Value: "6"},
{Name: "7", Value: "8"},
},
APIVersion: "client.authentication.k8s.io/v1alpha1",
}
c3 := &api.ExecConfig{
Command: "foo-bar",
Args: []string{"1", "2"},
Env: []api.ExecEnvVar{
{Name: "3", Value: "4"},
{Name: "5", Value: "6"},
},
APIVersion: "client.authentication.k8s.io/v1alpha1",
}
key1 := cacheKey(c1)
key2 := cacheKey(c2)
key3 := cacheKey(c3)
if key1 != key2 {
t.Error("key1 and key2 didn't match")
}
if key1 == key3 {
t.Error("key1 and key3 matched")
}
if key2 == key3 {
t.Error("key2 and key3 matched")
}
}
func compJSON(t *testing.T, got, want []byte) {
t.Helper()
gotJSON := &bytes.Buffer{}
wantJSON := &bytes.Buffer{}
if err := json.Indent(gotJSON, got, "", " "); err != nil {
t.Errorf("got invalid JSON: %v", err)
}
if err := json.Indent(wantJSON, want, "", " "); err != nil {
t.Errorf("want invalid JSON: %v", err)
}
g := strings.TrimSpace(gotJSON.String())
w := strings.TrimSpace(wantJSON.String())
if g != w {
t.Errorf("wanted %q, got %q", w, g)
}
}
func TestGetToken(t *testing.T) {
tests := []struct {
name string
config api.ExecConfig
output string
interactive bool
response *clientauthentication.Response
wantInput string
wantToken string
wantExpiry time.Time
wantErr bool
}{
{
name: "basic-request",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {}
}`,
output: `{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "foo-bar"
}
}`,
wantToken: "foo-bar",
},
{
name: "interactive",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
interactive: true,
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {
"interactive": true
}
}`,
output: `{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "foo-bar"
}
}`,
wantToken: "foo-bar",
},
{
name: "response",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
response: &clientauthentication.Response{
Header: map[string][]string{
"WWW-Authenticate": {`Basic realm="Access to the staging site", charset="UTF-8"`},
},
Code: 401,
},
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {
"response": {
"header": {
"WWW-Authenticate": [
"Basic realm=\"Access to the staging site\", charset=\"UTF-8\""
]
},
"code": 401
}
}
}`,
output: `{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "foo-bar"
}
}`,
wantToken: "foo-bar",
},
{
name: "expiry",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {}
}`,
output: `{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "foo-bar",
"expirationTimestamp": "2006-01-02T15:04:05Z"
}
}`,
wantExpiry: time.Date(2006, 01, 02, 15, 04, 05, 0, time.UTC),
wantToken: "foo-bar",
},
{
name: "no-group-version",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {}
}`,
output: `{
"kind": "ExecCredential",
"status": {
"token": "foo-bar"
}
}`,
wantErr: true,
},
{
name: "no-status",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {}
}`,
output: `{
"kind": "ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1"
}`,
wantErr: true,
},
{
name: "no-token",
config: api.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1alpha1",
},
wantInput: `{
"kind":"ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"spec": {}
}`,
output: `{
"kind": "ExecCredential",
"apiVersion":"client.authentication.k8s.io/v1alpha1",
"status": {}
}`,
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
c := test.config
c.Command = "./testdata/test-plugin.sh"
c.Env = append(c.Env, api.ExecEnvVar{
Name: "TEST_OUTPUT",
Value: test.output,
})
a, err := newAuthenticator(newCache(), &c)
if err != nil {
t.Fatal(err)
}
stderr := &bytes.Buffer{}
a.stderr = stderr
a.interactive = test.interactive
a.environ = func() []string { return nil }
token, err := a.getToken(test.response)
if err != nil {
if !test.wantErr {
t.Errorf("get token %v", err)
}
return
}
if test.wantErr {
t.Fatal("expected error getting token")
}
if token != test.wantToken {
t.Errorf("expected token %q got %q", test.wantToken, token)
}
if !a.exp.Equal(test.wantExpiry) {
t.Errorf("expected expiry %v got %v", test.wantExpiry, a.exp)
}
compJSON(t, stderr.Bytes(), []byte(test.wantInput))
})
}
}
func TestRoundTripper(t *testing.T) {
wantToken := ""
n := time.Now()
now := func() time.Time { return n }
env := []string{""}
environ := func() []string {
s := make([]string, len(env))
copy(s, env)
return s
}
setOutput := func(s string) {
env[0] = "TEST_OUTPUT=" + s
}
handler := func(w http.ResponseWriter, r *http.Request) {
gotToken := ""
parts := strings.Split(r.Header.Get("Authorization"), " ")
if len(parts) > 1 && strings.EqualFold(parts[0], "bearer") {
gotToken = parts[1]
}
if wantToken != gotToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "ok")
}
server := httptest.NewServer(http.HandlerFunc(handler))
c := api.ExecConfig{
Command: "./testdata/test-plugin.sh",
APIVersion: "client.authentication.k8s.io/v1alpha1",
}
a, err := newAuthenticator(newCache(), &c)
if err != nil {
t.Fatal(err)
}
a.environ = environ
a.now = now
a.stderr = ioutil.Discard
client := http.Client{
Transport: a.WrapTransport(http.DefaultTransport),
}
get := func(t *testing.T, statusCode int) {
t.Helper()
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != statusCode {
t.Errorf("wanted status %d got %d", statusCode, resp.StatusCode)
}
}
setOutput(`{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "token1"
}
}`)
wantToken = "token1"
get(t, http.StatusOK)
setOutput(`{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "token2"
}
}`)
// Previous token should be cached
get(t, http.StatusOK)
wantToken = "token2"
// Token is still cached, hits unauthorized but causes token to rotate.
get(t, http.StatusUnauthorized)
// Follow up request uses the rotated token.
get(t, http.StatusOK)
setOutput(`{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "token3",
"expirationTimestamp": "` + now().Add(time.Hour).Format(time.RFC3339Nano) + `"
}
}`)
wantToken = "token3"
// Token is still cached, hit's unauthorized but causes rotation to token with an expiry.
get(t, http.StatusUnauthorized)
get(t, http.StatusOK)
// Move time forward 2 hours, "token3" is now expired.
n = n.Add(time.Hour * 2)
setOutput(`{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1alpha1",
"status": {
"token": "token4",
"expirationTimestamp": "` + now().Add(time.Hour).Format(time.RFC3339Nano) + `"
}
}`)
wantToken = "token4"
// Old token is expired, should refresh automatically without hitting a 401.
get(t, http.StatusOK)
}

View File

@ -0,0 +1,18 @@
#!/bin/sh -e
# Copyright 2018 The Kubernetes Authors.
#
# 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.
>&2 echo "$KUBERNETES_EXEC_INFO"
echo "$TEST_OUTPUT"

View File

@ -77,6 +77,9 @@ type Config struct {
// Callback to persist config for AuthProvider. // Callback to persist config for AuthProvider.
AuthConfigPersister AuthProviderConfigPersister AuthConfigPersister AuthProviderConfigPersister
// Exec-based authentication provider.
ExecProvider *clientcmdapi.ExecConfig
// TLSClientConfig contains settings to enable transport layer security // TLSClientConfig contains settings to enable transport layer security
TLSClientConfig TLSClientConfig
@ -432,6 +435,7 @@ func CopyConfig(config *Config) *Config {
}, },
AuthProvider: config.AuthProvider, AuthProvider: config.AuthProvider,
AuthConfigPersister: config.AuthConfigPersister, AuthConfigPersister: config.AuthConfigPersister,
ExecProvider: config.ExecProvider,
TLSClientConfig: TLSClientConfig{ TLSClientConfig: TLSClientConfig{
Insecure: config.TLSClientConfig.Insecure, Insecure: config.TLSClientConfig.Insecure,
ServerName: config.TLSClientConfig.ServerName, ServerName: config.TLSClientConfig.ServerName,

View File

@ -269,6 +269,7 @@ func TestAnonymousConfig(t *testing.T) {
expected.Password = "" expected.Password = ""
expected.AuthProvider = nil expected.AuthProvider = nil
expected.AuthConfigPersister = nil expected.AuthConfigPersister = nil
expected.ExecProvider = nil
expected.TLSClientConfig.CertData = nil expected.TLSClientConfig.CertData = nil
expected.TLSClientConfig.CertFile = "" expected.TLSClientConfig.CertFile = ""
expected.TLSClientConfig.KeyData = nil expected.TLSClientConfig.KeyData = nil

View File

@ -20,6 +20,7 @@ import (
"crypto/tls" "crypto/tls"
"net/http" "net/http"
"k8s.io/client-go/plugin/pkg/client/auth/exec"
"k8s.io/client-go/transport" "k8s.io/client-go/transport"
) )
@ -59,6 +60,20 @@ func HTTPWrappersForConfig(config *Config, rt http.RoundTripper) (http.RoundTrip
// TransportConfig converts a client config to an appropriate transport config. // TransportConfig converts a client config to an appropriate transport config.
func (c *Config) TransportConfig() (*transport.Config, error) { func (c *Config) TransportConfig() (*transport.Config, error) {
wt := c.WrapTransport wt := c.WrapTransport
if c.ExecProvider != nil {
provider, err := exec.GetAuthenticator(c.ExecProvider)
if err != nil {
return nil, err
}
if wt != nil {
previousWT := wt
wt = func(rt http.RoundTripper) http.RoundTripper {
return provider.WrapTransport(previousWT(rt))
}
} else {
wt = provider.WrapTransport
}
}
if c.AuthProvider != nil { if c.AuthProvider != nil {
provider, err := GetAuthProvider(c.Host, c.AuthProvider, c.AuthConfigPersister) provider, err := GetAuthProvider(c.Host, c.AuthProvider, c.AuthConfigPersister)
if err != nil { if err != nil {

View File

@ -119,6 +119,9 @@ type AuthInfo struct {
// AuthProvider specifies a custom authentication plugin for the kubernetes cluster. // AuthProvider specifies a custom authentication plugin for the kubernetes cluster.
// +optional // +optional
AuthProvider *AuthProviderConfig `json:"auth-provider,omitempty"` AuthProvider *AuthProviderConfig `json:"auth-provider,omitempty"`
// Exec specifies a custom exec-based authentication plugin for the kubernetes cluster.
// +optional
Exec *ExecConfig `json:"exec,omitempty"`
// Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields // Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields
// +optional // +optional
Extensions map[string]runtime.Object `json:"extensions,omitempty"` Extensions map[string]runtime.Object `json:"extensions,omitempty"`
@ -147,6 +150,35 @@ type AuthProviderConfig struct {
Config map[string]string `json:"config,omitempty"` Config map[string]string `json:"config,omitempty"`
} }
// ExecConfig specifies a command to provide client credentials. The command is exec'd
// and outputs structured stdout holding credentials.
//
// See the client.authentiction.k8s.io API group for specifications of the exact input
// and output format
type ExecConfig struct {
// Command to execute.
Command string `json:"command"`
// Arguments to pass to the command when executing it.
// +optional
Args []string `json:"args"`
// Env defines additional environment variables to expose to the process. These
// are unioned with the host's environment, as well as variables client-go uses
// to pass argument to the plugin.
// +optional
Env []ExecEnvVar `json:"env"`
// Preferred input version of the ExecInfo. The returned ExecCredentials MUST use
// the same encoding version as the input.
APIVersion string `json:"apiVersion,omitempty"`
}
// ExecEnvVar is used for setting environment variables when executing an exec-based
// credential plugin.
type ExecEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
// NewConfig is a convenience function that returns a new Config object with non-nil maps // NewConfig is a convenience function that returns a new Config object with non-nil maps
func NewConfig() *Config { func NewConfig() *Config {
return &Config{ return &Config{

View File

@ -113,6 +113,9 @@ type AuthInfo struct {
// AuthProvider specifies a custom authentication plugin for the kubernetes cluster. // AuthProvider specifies a custom authentication plugin for the kubernetes cluster.
// +optional // +optional
AuthProvider *AuthProviderConfig `json:"auth-provider,omitempty"` AuthProvider *AuthProviderConfig `json:"auth-provider,omitempty"`
// Exec specifies a custom exec-based authentication plugin for the kubernetes cluster.
// +optional
Exec *ExecConfig `json:"exec,omitempty"`
// Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields // Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields
// +optional // +optional
Extensions []NamedExtension `json:"extensions,omitempty"` Extensions []NamedExtension `json:"extensions,omitempty"`
@ -169,3 +172,32 @@ type AuthProviderConfig struct {
Name string `json:"name"` Name string `json:"name"`
Config map[string]string `json:"config"` Config map[string]string `json:"config"`
} }
// ExecConfig specifies a command to provide client credentials. The command is exec'd
// and outputs structured stdout holding credentials.
//
// See the client.authentiction.k8s.io API group for specifications of the exact input
// and output format
type ExecConfig struct {
// Command to execute.
Command string `json:"command"`
// Arguments to pass to the command when executing it.
// +optional
Args []string `json:"args"`
// Env defines additional environment variables to expose to the process. These
// are unioned with the host's environment, as well as variables client-go uses
// to pass argument to the plugin.
// +optional
Env []ExecEnvVar `json:"env"`
// Preferred input version of the ExecInfo. The returned ExecCredentials MUST use
// the same encoding version as the input.
APIVersion string `json:"apiVersion,omitempty"`
}
// ExecEnvVar is used for setting environment variables when executing an exec-based
// credential plugin.
type ExecEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}

View File

@ -241,6 +241,9 @@ func (config *DirectClientConfig) getUserIdentificationPartialConfig(configAuthI
mergedConfig.AuthProvider = configAuthInfo.AuthProvider mergedConfig.AuthProvider = configAuthInfo.AuthProvider
mergedConfig.AuthConfigPersister = persistAuthConfig mergedConfig.AuthConfigPersister = persistAuthConfig
} }
if configAuthInfo.Exec != nil {
mergedConfig.ExecProvider = configAuthInfo.Exec
}
// if there still isn't enough information to authenticate the user, try prompting // if there still isn't enough information to authenticate the user, try prompting
if !canIdentifyUser(*mergedConfig) && (fallbackReader != nil) { if !canIdentifyUser(*mergedConfig) && (fallbackReader != nil) {
@ -291,7 +294,8 @@ func canIdentifyUser(config restclient.Config) bool {
return len(config.Username) > 0 || return len(config.Username) > 0 ||
(len(config.CertFile) > 0 || len(config.CertData) > 0) || (len(config.CertFile) > 0 || len(config.CertData) > 0) ||
len(config.BearerToken) > 0 || len(config.BearerToken) > 0 ||
config.AuthProvider != nil config.AuthProvider != nil ||
config.ExecProvider != nil
} }
// Namespace implements ClientConfig // Namespace implements ClientConfig

View File

@ -557,7 +557,12 @@ func GetClusterFileReferences(cluster *clientcmdapi.Cluster) []*string {
} }
func GetAuthInfoFileReferences(authInfo *clientcmdapi.AuthInfo) []*string { func GetAuthInfoFileReferences(authInfo *clientcmdapi.AuthInfo) []*string {
return []*string{&authInfo.ClientCertificate, &authInfo.ClientKey, &authInfo.TokenFile} s := []*string{&authInfo.ClientCertificate, &authInfo.ClientKey, &authInfo.TokenFile}
// Only resolve exec command if it isn't PATH based.
if authInfo.Exec != nil && strings.ContainsRune(authInfo.Exec.Command, filepath.Separator) {
s = append(s, &authInfo.Exec.Command)
}
return s
} }
// ResolvePaths updates the given refs to be absolute paths, relative to the given base directory // ResolvePaths updates the given refs to be absolute paths, relative to the given base directory

View File

@ -206,6 +206,9 @@ func TestResolveRelativePaths(t *testing.T) {
AuthInfos: map[string]*clientcmdapi.AuthInfo{ AuthInfos: map[string]*clientcmdapi.AuthInfo{
"relative-user-1": {ClientCertificate: "relative/client/cert", ClientKey: "../relative/client/key"}, "relative-user-1": {ClientCertificate: "relative/client/cert", ClientKey: "../relative/client/key"},
"absolute-user-1": {ClientCertificate: "/absolute/client/cert", ClientKey: "/absolute/client/key"}, "absolute-user-1": {ClientCertificate: "/absolute/client/cert", ClientKey: "/absolute/client/key"},
"relative-cmd-1": {Exec: &clientcmdapi.ExecConfig{Command: "../relative/client/cmd"}},
"absolute-cmd-1": {Exec: &clientcmdapi.ExecConfig{Command: "/absolute/client/cmd"}},
"PATH-cmd-1": {Exec: &clientcmdapi.ExecConfig{Command: "cmd"}},
}, },
Clusters: map[string]*clientcmdapi.Cluster{ Clusters: map[string]*clientcmdapi.Cluster{
"relative-server-1": {CertificateAuthority: "../relative/ca"}, "relative-server-1": {CertificateAuthority: "../relative/ca"},
@ -291,9 +294,21 @@ func TestResolveRelativePaths(t *testing.T) {
matchStringArg(pathResolutionConfig2.AuthInfos["absolute-user-2"].ClientCertificate, authInfo.ClientCertificate, t) matchStringArg(pathResolutionConfig2.AuthInfos["absolute-user-2"].ClientCertificate, authInfo.ClientCertificate, t)
matchStringArg(pathResolutionConfig2.AuthInfos["absolute-user-2"].ClientKey, authInfo.ClientKey, t) matchStringArg(pathResolutionConfig2.AuthInfos["absolute-user-2"].ClientKey, authInfo.ClientKey, t)
} }
if key == "relative-cmd-1" {
foundAuthInfoCount++
matchStringArg(path.Join(configDir1, pathResolutionConfig1.AuthInfos[key].Exec.Command), authInfo.Exec.Command, t)
}
if key == "absolute-cmd-1" {
foundAuthInfoCount++
matchStringArg(pathResolutionConfig1.AuthInfos[key].Exec.Command, authInfo.Exec.Command, t)
}
if key == "PATH-cmd-1" {
foundAuthInfoCount++
matchStringArg(pathResolutionConfig1.AuthInfos[key].Exec.Command, authInfo.Exec.Command, t)
}
} }
if foundAuthInfoCount != 4 { if foundAuthInfoCount != 7 {
t.Errorf("Expected 4 users, found %v: %v", foundAuthInfoCount, mergedConfig.AuthInfos) t.Errorf("Expected 7 users, found %v: %v", foundAuthInfoCount, mergedConfig.AuthInfos)
} }
} }

View File

@ -237,6 +237,25 @@ func validateAuthInfo(authInfoName string, authInfo clientcmdapi.AuthInfo) []err
} }
} }
if authInfo.Exec != nil {
if authInfo.AuthProvider != nil {
validationErrors = append(validationErrors, fmt.Errorf("authProvider cannot be provided in combination with an exec plugin for %s", authInfoName))
}
if len(authInfo.Exec.Command) == 0 {
validationErrors = append(validationErrors, fmt.Errorf("command must be specified for %v to use exec authentication plugin", authInfoName))
}
if len(authInfo.Exec.APIVersion) == 0 {
validationErrors = append(validationErrors, fmt.Errorf("apiVersion must be specified for %v to use exec authentication plugin", authInfoName))
}
for _, v := range authInfo.Exec.Env {
if len(v.Name) == 0 {
validationErrors = append(validationErrors, fmt.Errorf("env variable name must be specified for %v to use exec authentication plugin", authInfoName))
} else if len(v.Value) == 0 {
validationErrors = append(validationErrors, fmt.Errorf("env variable %s value must be specified for %v to use exec authentication plugin", v.Name, authInfoName))
}
}
}
// authPath also provides information for the client to identify the server, so allow multiple auth methods in that case // authPath also provides information for the client to identify the server, so allow multiple auth methods in that case
if (len(methods) > 1) && (!usingAuthPath) { if (len(methods) > 1) && (!usingAuthPath) {
validationErrors = append(validationErrors, fmt.Errorf("more than one authentication method found for %v; found %v, only one is allowed", authInfoName, methods)) validationErrors = append(validationErrors, fmt.Errorf("more than one authentication method found for %v; found %v, only one is allowed", authInfoName, methods))

View File

@ -365,6 +365,106 @@ func TestValidateMultipleMethodsAuthInfo(t *testing.T) {
test.testConfig(t) test.testConfig(t)
} }
func TestValidateAuthInfoExec(t *testing.T) {
config := clientcmdapi.NewConfig()
config.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Exec: &clientcmdapi.ExecConfig{
Command: "/bin/example",
APIVersion: "clientauthentication.k8s.io/v1alpha1",
Args: []string{"hello", "world"},
Env: []clientcmdapi.ExecEnvVar{
{Name: "foo", Value: "bar"},
},
},
}
test := configValidationTest{
config: config,
}
test.testAuthInfo("user", t)
test.testConfig(t)
}
func TestValidateAuthInfoExecNoVersion(t *testing.T) {
config := clientcmdapi.NewConfig()
config.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Exec: &clientcmdapi.ExecConfig{
Command: "/bin/example",
},
}
test := configValidationTest{
config: config,
expectedErrorSubstring: []string{
"apiVersion must be specified for user to use exec authentication plugin",
},
}
test.testAuthInfo("user", t)
test.testConfig(t)
}
func TestValidateAuthInfoExecNoCommand(t *testing.T) {
config := clientcmdapi.NewConfig()
config.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Exec: &clientcmdapi.ExecConfig{
APIVersion: "clientauthentication.k8s.io/v1alpha1",
},
}
test := configValidationTest{
config: config,
expectedErrorSubstring: []string{
"command must be specified for user to use exec authentication plugin",
},
}
test.testAuthInfo("user", t)
test.testConfig(t)
}
func TestValidateAuthInfoExecWithAuthProvider(t *testing.T) {
config := clientcmdapi.NewConfig()
config.AuthInfos["user"] = &clientcmdapi.AuthInfo{
AuthProvider: &clientcmdapi.AuthProviderConfig{
Name: "oidc",
},
Exec: &clientcmdapi.ExecConfig{
Command: "/bin/example",
APIVersion: "clientauthentication.k8s.io/v1alpha1",
},
}
test := configValidationTest{
config: config,
expectedErrorSubstring: []string{
"authProvider cannot be provided in combination with an exec plugin for user",
},
}
test.testAuthInfo("user", t)
test.testConfig(t)
}
func TestValidateAuthInfoExecInvalidEnv(t *testing.T) {
config := clientcmdapi.NewConfig()
config.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Exec: &clientcmdapi.ExecConfig{
Command: "/bin/example",
APIVersion: "clientauthentication.k8s.io/v1alpha1",
Env: []clientcmdapi.ExecEnvVar{
{Name: "foo"}, // No value
},
},
}
test := configValidationTest{
config: config,
expectedErrorSubstring: []string{
"env variable foo value must be specified for user to use exec authentication plugin",
},
}
test.testAuthInfo("user", t)
test.testConfig(t)
}
type configValidationTest struct { type configValidationTest struct {
config *clientcmdapi.Config config *clientcmdapi.Config
expectedErrorSubstring []string expectedErrorSubstring []string