From 9b7fe01648e3168be6746e2a9595a4c20fff3f71 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Fri, 11 Dec 2020 15:28:19 -0600 Subject: [PATCH 01/16] Add a new ./pkg/conciergeclient package to replace ./internal/client. This is a slighly evolved version of our previous client package, exported to be public and refactored to use functional options for API maintainability. Signed-off-by: Matt Moyer --- pkg/conciergeclient/conciergeclient.go | 193 ++++++++++++++ pkg/conciergeclient/conciergeclient_test.go | 263 ++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 pkg/conciergeclient/conciergeclient.go create mode 100644 pkg/conciergeclient/conciergeclient_test.go diff --git a/pkg/conciergeclient/conciergeclient.go b/pkg/conciergeclient/conciergeclient.go new file mode 100644 index 00000000..736ee03c --- /dev/null +++ b/pkg/conciergeclient/conciergeclient.go @@ -0,0 +1,193 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package conciergeclient provides login helpers for the Pinniped concierge. +package conciergeclient + +import ( + "context" + "crypto/x509" + "encoding/base64" + "fmt" + "net/url" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" + loginv1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/login/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" + "go.pinniped.dev/internal/constable" +) + +// ErrLoginFailed is returned by Client.ExchangeToken when the concierge server rejects the login request for any reason. +var ErrLoginFailed = constable.Error("login failed") + +// Option is an optional configuration for New(). +type Option func(*Client) error + +// Client is a configuration for talking to the Pinniped concierge. +type Client struct { + namespace string + authenticator *corev1.TypedLocalObjectReference + caBundle string + endpoint *url.URL +} + +// WithNamespace configures the namespace where the TokenCredentialRequest is to be sent. +func WithNamespace(namespace string) Option { + return func(c *Client) error { + c.namespace = namespace + return nil + } +} + +// WithAuthenticator configures the authenticator reference (spec.authenticator) of the TokenCredentialRequests. +func WithAuthenticator(authType, authName string) Option { + return func(c *Client) error { + if authName == "" { + return fmt.Errorf("authenticator name must not be empty") + } + authenticator := corev1.TypedLocalObjectReference{Name: authName} + switch strings.ToLower(authType) { + case "webhook": + authenticator.APIGroup = &auth1alpha1.SchemeGroupVersion.Group + authenticator.Kind = "WebhookAuthenticator" + case "jwt": + authenticator.APIGroup = &auth1alpha1.SchemeGroupVersion.Group + authenticator.Kind = "JWTAuthenticator" + default: + return fmt.Errorf(`invalid authenticator type: %q, supported values are "webhook" and "jwt"`, authType) + } + c.authenticator = &authenticator + return nil + } +} + +// WithCABundle configures the PEM-formatted TLS certificate authority to trust when connecting to the concierge. +func WithCABundle(caBundle string) Option { + return func(c *Client) error { + if caBundle == "" { + return nil + } + if p := x509.NewCertPool(); !p.AppendCertsFromPEM([]byte(caBundle)) { + return fmt.Errorf("invalid CA bundle data: no certificates found") + } + c.caBundle = caBundle + return nil + } +} + +// WithBase64CABundle configures the base64-encoded, PEM-formatted TLS certificate authority to trust when connecting to the concierge. +func WithBase64CABundle(caBundleBase64 string) Option { + return func(c *Client) error { + caBundle, err := base64.StdEncoding.DecodeString(caBundleBase64) + if err != nil { + return fmt.Errorf("invalid CA bundle data: %w", err) + } + return WithCABundle(string(caBundle))(c) + } +} + +// WithEndpoint configures the base API endpoint URL of the concierge service (same as Kubernetes API server). +func WithEndpoint(endpoint string) Option { + return func(c *Client) error { + if endpoint == "" { + return fmt.Errorf("endpoint must not be empty") + } + u, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + if u.Scheme != "https" { + return fmt.Errorf(`invalid endpoint scheme %q (must be "https")`, u.Scheme) + } + c.endpoint = u + return nil + } +} + +// New validates the specified options and returns a newly initialized *Client. +func New(opts ...Option) (*Client, error) { + c := Client{namespace: "pinniped-concierge"} + for _, opt := range opts { + if err := opt(&c); err != nil { + return nil, err + } + } + if c.authenticator == nil { + return nil, fmt.Errorf("WithAuthenticator must be specified") + } + if c.endpoint == nil { + return nil, fmt.Errorf("WithEndpoint must be specified") + } + return &c, nil +} + +// clientset returns an anonymous client for the concierge API. +func (c *Client) clientset() (conciergeclientset.Interface, error) { + cfg, err := clientcmd.NewNonInteractiveClientConfig(clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "cluster": { + Server: c.endpoint.String(), + CertificateAuthorityData: []byte(c.caBundle), + }, + }, + Contexts: map[string]*clientcmdapi.Context{ + "current": { + Cluster: "cluster", + AuthInfo: "client", + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "client": {}, + }, + }, "current", &clientcmd.ConfigOverrides{}, nil).ClientConfig() + if err != nil { + return nil, err + } + return conciergeclientset.NewForConfig(cfg) +} + +// ExchangeToken performs a TokenCredentialRequest against the Pinniped concierge and returns the result as an ExecCredential. +func (c *Client) ExchangeToken(ctx context.Context, token string) (*clientauthenticationv1beta1.ExecCredential, error) { + clientset, err := c.clientset() + if err != nil { + return nil, err + } + resp, err := clientset.LoginV1alpha1().TokenCredentialRequests(c.namespace).Create(ctx, &loginv1alpha1.TokenCredentialRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: c.namespace, + }, + Spec: loginv1alpha1.TokenCredentialRequestSpec{ + Token: token, + Authenticator: *c.authenticator, + }, + }, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("could not login: %w", err) + } + if resp.Status.Credential == nil || resp.Status.Message != nil { + if resp.Status.Message != nil { + return nil, fmt.Errorf("%w: %s", ErrLoginFailed, *resp.Status.Message) + } + return nil, fmt.Errorf("%w: unknown cause", ErrLoginFailed) + } + + return &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + ExpirationTimestamp: &resp.Status.Credential.ExpirationTimestamp, + ClientCertificateData: resp.Status.Credential.ClientCertificateData, + ClientKeyData: resp.Status.Credential.ClientKeyData, + Token: resp.Status.Credential.Token, + }, + }, nil +} diff --git a/pkg/conciergeclient/conciergeclient_test.go b/pkg/conciergeclient/conciergeclient_test.go new file mode 100644 index 00000000..b8d5cd3b --- /dev/null +++ b/pkg/conciergeclient/conciergeclient_test.go @@ -0,0 +1,263 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package conciergeclient + +import ( + "context" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + loginv1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/login/v1alpha1" + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/testutil" +) + +func TestNew(t *testing.T) { + t.Parallel() + testCA, err := certauthority.New(pkix.Name{}, 1*time.Hour) + require.NoError(t, err) + + tests := []struct { + name string + opts []Option + wantErr string + }{ + { + name: "some option error", + opts: []Option{ + func(client *Client) error { return fmt.Errorf("some error") }, + }, + wantErr: "some error", + }, + { + name: "with invalid authenticator", + opts: []Option{ + WithAuthenticator("invalid-type", "test-authenticator"), + }, + wantErr: `invalid authenticator type: "invalid-type", supported values are "webhook" and "jwt"`, + }, + { + name: "with empty authenticator name", + opts: []Option{ + WithAuthenticator("webhook", ""), + }, + wantErr: `authenticator name must not be empty`, + }, + { + name: "invalid CA bundle", + opts: []Option{ + WithCABundle("invalid-base64"), + }, + wantErr: "invalid CA bundle data: no certificates found", + }, + { + name: "invalid base64 CA bundle", + opts: []Option{ + WithBase64CABundle("invalid-base64"), + }, + wantErr: "invalid CA bundle data: illegal base64 data at input byte 7", + }, + { + name: "empty endpoint", + opts: []Option{ + WithEndpoint(""), + }, + wantErr: `endpoint must not be empty`, + }, + { + name: "invalid endpoint", + opts: []Option{ + WithEndpoint("%"), + }, + wantErr: `invalid endpoint URL: parse "%": invalid URL escape "%"`, + }, + { + name: "non-https endpoint", + opts: []Option{ + WithEndpoint("http://example.com"), + }, + wantErr: `invalid endpoint scheme "http" (must be "https")`, + }, + { + name: "missing authenticator", + opts: []Option{ + WithEndpoint("https://example.com"), + }, + wantErr: "WithAuthenticator must be specified", + }, + { + name: "missing endpoint", + opts: []Option{ + WithAuthenticator("jwt", "test-authenticator"), + }, + wantErr: "WithEndpoint must be specified", + }, + { + name: "valid", + opts: []Option{ + WithNamespace("test-namespace"), + WithEndpoint("https://example.com"), + WithCABundle(""), + WithCABundle(string(testCA.Bundle())), + WithBase64CABundle(base64.StdEncoding.EncodeToString(testCA.Bundle())), + WithAuthenticator("jwt", "test-authenticator"), + WithAuthenticator("webhook", "test-authenticator"), + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := New(tt.opts...) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} + +func TestExchangeToken(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("clientset failure", func(t *testing.T) { + c := Client{endpoint: &url.URL{}} + _, err := c.ExchangeToken(ctx, "") + require.EqualError(t, err, "invalid configuration: no configuration has been provided, try setting KUBERNETES_MASTER environment variable") + }) + + t.Run("server error", func(t *testing.T) { + t.Parallel() + // Start a test server that returns only 500 errors. + caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("some server error")) + }) + + client, err := New(WithEndpoint(endpoint), WithCABundle(caBundle), WithAuthenticator("jwt", "test-authenticator")) + require.NoError(t, err) + + got, err := client.ExchangeToken(ctx, "test-token") + require.EqualError(t, err, `could not login: an error on the server ("some server error") has prevented the request from succeeding (post tokencredentialrequests.login.concierge.pinniped.dev)`) + require.Nil(t, got) + }) + + t.Run("login failure", func(t *testing.T) { + t.Parallel() + // Start a test server that returns success but with an error message + errorMessage := "some login failure" + caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&loginv1alpha1.TokenCredentialRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "login.concierge.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"}, + Status: loginv1alpha1.TokenCredentialRequestStatus{Message: &errorMessage}, + }) + }) + + client, err := New(WithEndpoint(endpoint), WithCABundle(caBundle), WithAuthenticator("jwt", "test-authenticator")) + require.NoError(t, err) + + got, err := client.ExchangeToken(ctx, "test-token") + require.EqualError(t, err, `login failed: some login failure`) + require.Nil(t, got) + }) + + t.Run("login failure unknown error", func(t *testing.T) { + t.Parallel() + // Start a test server that returns without any error message but also without valid credentials + caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&loginv1alpha1.TokenCredentialRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "login.concierge.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"}, + }) + }) + + client, err := New(WithEndpoint(endpoint), WithCABundle(caBundle), WithAuthenticator("jwt", "test-authenticator")) + require.NoError(t, err) + + got, err := client.ExchangeToken(ctx, "test-token") + require.EqualError(t, err, `login failed: unknown cause`) + require.Nil(t, got) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + expires := metav1.NewTime(time.Now().Truncate(time.Second)) + + // Start a test server that returns successfully and asserts various properties of the request. + caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/apis/login.concierge.pinniped.dev/v1alpha1/namespaces/test-namespace/tokencredentialrequests", r.URL.Path) + require.Equal(t, "application/json", r.Header.Get("content-type")) + + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + require.JSONEq(t, + `{ + "kind": "TokenCredentialRequest", + "apiVersion": "login.concierge.pinniped.dev/v1alpha1", + "metadata": { + "creationTimestamp": null, + "namespace": "test-namespace" + }, + "spec": { + "token": "test-token", + "authenticator": { + "apiGroup": "authentication.concierge.pinniped.dev", + "kind": "WebhookAuthenticator", + "name": "test-webhook" + } + }, + "status": {} + }`, + string(body), + ) + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&loginv1alpha1.TokenCredentialRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "login.concierge.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"}, + Status: loginv1alpha1.TokenCredentialRequestStatus{ + Credential: &loginv1alpha1.ClusterCredential{ + ExpirationTimestamp: expires, + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + }, + }, + }) + }) + + client, err := New(WithNamespace("test-namespace"), WithEndpoint(endpoint), WithCABundle(caBundle), WithAuthenticator("webhook", "test-webhook")) + require.NoError(t, err) + + got, err := client.ExchangeToken(ctx, "test-token") + require.NoError(t, err) + require.Equal(t, &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + ExpirationTimestamp: &expires, + }, + }, got) + }) +} From 3b5f00439c6afcee040b9a11db1bbfd0ba46f02b Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Fri, 11 Dec 2020 15:48:50 -0600 Subject: [PATCH 02/16] Remove `pinniped exchange-credential` CLI subcommand. Signed-off-by: Matt Moyer --- cmd/pinniped/cmd/exchange_credential.go | 173 ---------- cmd/pinniped/cmd/exchange_credential_test.go | 342 ------------------- 2 files changed, 515 deletions(-) delete mode 100644 cmd/pinniped/cmd/exchange_credential.go delete mode 100644 cmd/pinniped/cmd/exchange_credential_test.go diff --git a/cmd/pinniped/cmd/exchange_credential.go b/cmd/pinniped/cmd/exchange_credential.go deleted file mode 100644 index 74d1176b..00000000 --- a/cmd/pinniped/cmd/exchange_credential.go +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - corev1 "k8s.io/api/core/v1" - clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" - - auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" - "go.pinniped.dev/internal/client" - "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/plog" -) - -//nolint: gochecknoinits -func init() { - rootCmd.AddCommand(newExchangeCredentialCmd(os.Args, os.Stdout, os.Stderr).cmd) -} - -type exchangeCredentialCommand struct { - // runFunc is called by the cobra.Command.Run hook. It is included here for - // testability. - runFunc func(stdout, stderr io.Writer) - - // cmd is the cobra.Command for this CLI command. It is included here for - // testability. - cmd *cobra.Command -} - -func newExchangeCredentialCmd(args []string, stdout, stderr io.Writer) *exchangeCredentialCommand { - c := &exchangeCredentialCommand{ - runFunc: runExchangeCredential, - } - - c.cmd = &cobra.Command{ - Run: func(cmd *cobra.Command, _ []string) { - c.runFunc(stdout, stderr) - }, - Args: cobra.NoArgs, // do not accept positional arguments for this command - Use: "exchange-credential", - Short: "Exchange a credential for a cluster-specific access credential", - Long: here.Doc(` - Exchange a credential which proves your identity for a time-limited, - cluster-specific access credential. - - Designed to be conveniently used as an credential plugin for kubectl. - See the help message for 'pinniped get-kubeconfig' for more - information about setting up a kubeconfig file using Pinniped. - - Requires all of the following environment variables, which are - typically set in the kubeconfig: - - PINNIPED_TOKEN: the token to send to Pinniped for exchange - - PINNIPED_NAMESPACE: the namespace of the authenticator to authenticate - against - - PINNIPED_AUTHENTICATOR_TYPE: the type of authenticator to authenticate - against (e.g., "webhook", "jwt") - - PINNIPED_AUTHENTICATOR_NAME: the name of the authenticator to authenticate - against - - PINNIPED_CA_BUNDLE: the CA bundle to trust when calling - Pinniped's HTTPS endpoint - - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential - exchange API - - For more information about credential plugins in general, see - https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins - `), - } - - c.cmd.SetArgs(args) - c.cmd.SetOut(stdout) - c.cmd.SetErr(stderr) - - plog.RemoveKlogGlobalFlags() - - return c -} - -type envGetter func(string) (string, bool) -type tokenExchanger func( - ctx context.Context, - namespace string, - authenticator corev1.TypedLocalObjectReference, - token string, - caBundle string, - apiEndpoint string, -) (*clientauthenticationv1beta1.ExecCredential, error) - -const ( - ErrMissingEnvVar = constable.Error("failed to get credential: environment variable not set") - ErrInvalidAuthenticatorType = constable.Error("invalid authenticator type") -) - -func runExchangeCredential(stdout, _ io.Writer) { - err := exchangeCredential(os.LookupEnv, client.ExchangeToken, stdout, 30*time.Second) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - os.Exit(1) - } -} - -func exchangeCredential(envGetter envGetter, tokenExchanger tokenExchanger, outputWriter io.Writer, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - namespace, varExists := envGetter("PINNIPED_NAMESPACE") - if !varExists { - return envVarNotSetError("PINNIPED_NAMESPACE") - } - - authenticatorType, varExists := envGetter("PINNIPED_AUTHENTICATOR_TYPE") - if !varExists { - return envVarNotSetError("PINNIPED_AUTHENTICATOR_TYPE") - } - - authenticatorName, varExists := envGetter("PINNIPED_AUTHENTICATOR_NAME") - if !varExists { - return envVarNotSetError("PINNIPED_AUTHENTICATOR_NAME") - } - - token, varExists := envGetter("PINNIPED_TOKEN") - if !varExists { - return envVarNotSetError("PINNIPED_TOKEN") - } - - caBundle, varExists := envGetter("PINNIPED_CA_BUNDLE") - if !varExists { - return envVarNotSetError("PINNIPED_CA_BUNDLE") - } - - apiEndpoint, varExists := envGetter("PINNIPED_K8S_API_ENDPOINT") - if !varExists { - return envVarNotSetError("PINNIPED_K8S_API_ENDPOINT") - } - - authenticator := corev1.TypedLocalObjectReference{Name: authenticatorName} - switch strings.ToLower(authenticatorType) { - case "webhook": - authenticator.APIGroup = &auth1alpha1.SchemeGroupVersion.Group - authenticator.Kind = "WebhookAuthenticator" - case "jwt": - authenticator.APIGroup = &auth1alpha1.SchemeGroupVersion.Group - authenticator.Kind = "JWTAuthenticator" - default: - return fmt.Errorf(`%w: %q, supported values are "webhook" and "jwt"`, ErrInvalidAuthenticatorType, authenticatorType) - } - - cred, err := tokenExchanger(ctx, namespace, authenticator, token, caBundle, apiEndpoint) - if err != nil { - return fmt.Errorf("failed to get credential: %w", err) - } - - err = json.NewEncoder(outputWriter).Encode(cred) - if err != nil { - return fmt.Errorf("failed to marshal response to stdout: %w", err) - } - - return nil -} - -func envVarNotSetError(varName string) error { - return fmt.Errorf("%w: %s", ErrMissingEnvVar, varName) -} diff --git a/cmd/pinniped/cmd/exchange_credential_test.go b/cmd/pinniped/cmd/exchange_credential_test.go deleted file mode 100644 index babd4f3b..00000000 --- a/cmd/pinniped/cmd/exchange_credential_test.go +++ /dev/null @@ -1,342 +0,0 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bytes" - "context" - "fmt" - "io" - "testing" - "time" - - "github.com/sclevine/spec" - "github.com/sclevine/spec/report" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" - - auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" - "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/testutil" -) - -var ( - knownGoodUsageForExchangeCredential = here.Doc(` - Usage: - exchange-credential [flags] - - Flags: - -h, --help help for exchange-credential - - `) - - knownGoodHelpForExchangeCredential = here.Doc(` - Exchange a credential which proves your identity for a time-limited, - cluster-specific access credential. - - Designed to be conveniently used as an credential plugin for kubectl. - See the help message for 'pinniped get-kubeconfig' for more - information about setting up a kubeconfig file using Pinniped. - - Requires all of the following environment variables, which are - typically set in the kubeconfig: - - PINNIPED_TOKEN: the token to send to Pinniped for exchange - - PINNIPED_NAMESPACE: the namespace of the authenticator to authenticate - against - - PINNIPED_AUTHENTICATOR_TYPE: the type of authenticator to authenticate - against (e.g., "webhook", "jwt") - - PINNIPED_AUTHENTICATOR_NAME: the name of the authenticator to authenticate - against - - PINNIPED_CA_BUNDLE: the CA bundle to trust when calling - Pinniped's HTTPS endpoint - - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential - exchange API - - For more information about credential plugins in general, see - https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins - - Usage: - exchange-credential [flags] - - Flags: - -h, --help help for exchange-credential - `) -) - -func TestNewCredentialExchangeCmd(t *testing.T) { - spec.Run(t, "newCredentialExchangeCmd", func(t *testing.T, when spec.G, it spec.S) { - var r *require.Assertions - var stdout, stderr *bytes.Buffer - - it.Before(func() { - r = require.New(t) - - stdout, stderr = bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{}) - }) - - it("calls runFunc and does not print usage or help when correct arguments and flags are used", func() { - c := newExchangeCredentialCmd([]string{}, stdout, stderr) - - runFuncCalled := false - c.runFunc = func(out, err io.Writer) { - runFuncCalled = true - } - - r.NoError(c.cmd.Execute()) - r.True(runFuncCalled) - r.Empty(stdout.String()) - r.Empty(stderr.String()) - }) - - it("fails when args are passed", func() { - c := newExchangeCredentialCmd([]string{"some-arg"}, stdout, stderr) - - runFuncCalled := false - c.runFunc = func(out, err io.Writer) { - runFuncCalled = true - } - - errorMessage := `unknown command "some-arg" for "exchange-credential"` - r.EqualError(c.cmd.Execute(), errorMessage) - r.False(runFuncCalled) - - output := "Error: " + errorMessage + "\n" + knownGoodUsageForExchangeCredential - r.Equal(output, stdout.String()) - r.Empty(stderr.String()) - }) - - it("prints a nice help message", func() { - c := newExchangeCredentialCmd([]string{"--help"}, stdout, stderr) - - runFuncCalled := false - c.runFunc = func(out, err io.Writer) { - runFuncCalled = true - } - - r.NoError(c.cmd.Execute()) - r.False(runFuncCalled) - r.Equal(knownGoodHelpForExchangeCredential, stdout.String()) - r.Empty(stderr.String()) - }) - }, spec.Sequential(), spec.Report(report.Terminal{})) -} - -func TestExchangeCredential(t *testing.T) { - spec.Run(t, "cmd.exchangeCredential", func(t *testing.T, when spec.G, it spec.S) { - var r *require.Assertions - var buffer *bytes.Buffer - var tokenExchanger tokenExchanger - var fakeEnv map[string]string - - var envGetter envGetter = func(envVarName string) (string, bool) { - value, present := fakeEnv[envVarName] - if !present { - return "", false - } - return value, true - } - - it.Before(func() { - r = require.New(t) - buffer = new(bytes.Buffer) - fakeEnv = map[string]string{ - "PINNIPED_NAMESPACE": "namespace from env", - "PINNIPED_AUTHENTICATOR_TYPE": "Webhook", - "PINNIPED_AUTHENTICATOR_NAME": "webhook name from env", - "PINNIPED_TOKEN": "token from env", - "PINNIPED_CA_BUNDLE": "ca bundle from env", - "PINNIPED_K8S_API_ENDPOINT": "k8s api from env", - } - }) - - when("env vars are missing", func() { - it("returns an error when PINNIPED_NAMESPACE is missing", func() { - delete(fakeEnv, "PINNIPED_NAMESPACE") - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_NAMESPACE") - }) - - it("returns an error when PINNIPED_AUTHENTICATOR_TYPE is missing", func() { - delete(fakeEnv, "PINNIPED_AUTHENTICATOR_TYPE") - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_AUTHENTICATOR_TYPE") - }) - - it("returns an error when PINNIPED_AUTHENTICATOR_NAME is missing", func() { - delete(fakeEnv, "PINNIPED_AUTHENTICATOR_NAME") - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_AUTHENTICATOR_NAME") - }) - - it("returns an error when PINNIPED_TOKEN is missing", func() { - delete(fakeEnv, "PINNIPED_TOKEN") - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_TOKEN") - }) - - it("returns an error when PINNIPED_CA_BUNDLE is missing", func() { - delete(fakeEnv, "PINNIPED_CA_BUNDLE") - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_CA_BUNDLE") - }) - - it("returns an error when PINNIPED_K8S_API_ENDPOINT is missing", func() { - delete(fakeEnv, "PINNIPED_K8S_API_ENDPOINT") - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_K8S_API_ENDPOINT") - }) - }) - - when("env vars are invalid", func() { - it("returns an error when PINNIPED_AUTHENTICATOR_TYPE is missing", func() { - fakeEnv["PINNIPED_AUTHENTICATOR_TYPE"] = "invalid" - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, `invalid authenticator type: "invalid", supported values are "webhook" and "jwt"`) - }) - }) - - when("the token exchange fails", func() { - it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace string, authenticator corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { - return nil, fmt.Errorf("some error") - } - }) - - it("returns an error", func() { - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.EqualError(err, "failed to get credential: some error") - }) - }) - - when("the JSON encoder fails", func() { - it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace string, authenticator corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { - return &clientauthenticationv1beta1.ExecCredential{ - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - Token: "some token", - }, - }, nil - } - }) - - it("returns an error", func() { - err := exchangeCredential(envGetter, tokenExchanger, &testutil.ErrorWriter{ReturnError: fmt.Errorf("some IO error")}, 30*time.Second) - r.EqualError(err, "failed to marshal response to stdout: some IO error") - }) - }) - - when("the token exchange times out", func() { - it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace string, authenticator corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { - select { - case <-time.After(100 * time.Millisecond): - return &clientauthenticationv1beta1.ExecCredential{ - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - Token: "some token", - }, - }, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - } - }) - - it("returns an error", func() { - err := exchangeCredential(envGetter, tokenExchanger, buffer, 1*time.Millisecond) - r.EqualError(err, "failed to get credential: context deadline exceeded") - }) - }) - - when("the token exchange succeeds", func() { - var actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint string - - it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace string, authenticator corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { - actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint = namespace, token, caBundle, apiEndpoint - now := metav1.NewTime(time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC)) - return &clientauthenticationv1beta1.ExecCredential{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExecCredential", - APIVersion: "client.authentication.k8s.io/v1beta1", - }, - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - ExpirationTimestamp: &now, - ClientCertificateData: "some certificate", - ClientKeyData: "some key", - Token: "some token", - }, - }, nil - } - }) - - it("writes the execCredential to the given writer", func() { - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.NoError(err) - r.Equal(fakeEnv["PINNIPED_NAMESPACE"], actualNamespace) - r.Equal(fakeEnv["PINNIPED_TOKEN"], actualToken) - r.Equal(fakeEnv["PINNIPED_CA_BUNDLE"], actualCaBundle) - r.Equal(fakeEnv["PINNIPED_K8S_API_ENDPOINT"], actualAPIEndpoint) - expected := `{ - "kind": "ExecCredential", - "apiVersion": "client.authentication.k8s.io/v1beta1", - "spec": {}, - "status": { - "expirationTimestamp":"2020-07-29T01:02:03Z", - "clientCertificateData": "some certificate", - "clientKeyData":"some key", - "token": "some token" - } - }` - r.JSONEq(expected, buffer.String()) - }) - }) - - when("the authenticator info is passed", func() { - var actualAuthenticator corev1.TypedLocalObjectReference - - it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace string, authenticator corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { - actualAuthenticator = authenticator - return nil, nil - } - }) - - when("the authenticator is of type webhook", func() { - it.Before(func() { - fakeEnv["PINNIPED_AUTHENTICATOR_TYPE"] = "webhook" - fakeEnv["PINNIPED_AUTHENTICATOR_NAME"] = "some-webhook-name" - }) - - it("passes the correct authenticator type to the token exchanger", func() { - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.NoError(err) - require.Equal(t, corev1.TypedLocalObjectReference{ - APIGroup: &auth1alpha1.SchemeGroupVersion.Group, - Kind: "WebhookAuthenticator", - Name: "some-webhook-name", - }, actualAuthenticator) - }) - }) - - when("the authenticator is of type jwt", func() { - it.Before(func() { - fakeEnv["PINNIPED_AUTHENTICATOR_TYPE"] = "jwt" - fakeEnv["PINNIPED_AUTHENTICATOR_NAME"] = "some-jwt-authenticator-name" - }) - - it("passes the correct authenticator type to the token exchanger", func() { - err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) - r.NoError(err) - require.Equal(t, corev1.TypedLocalObjectReference{ - APIGroup: &auth1alpha1.SchemeGroupVersion.Group, - Kind: "JWTAuthenticator", - Name: "some-jwt-authenticator-name", - }, actualAuthenticator) - }) - }) - }) - }, spec.Parallel(), spec.Report(report.Terminal{})) -} From dfbb5b60de75d22d00e2d4f3a05b4d717ea1e533 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Mon, 14 Dec 2020 18:37:32 -0600 Subject: [PATCH 03/16] Remove `pinniped get-kubeconfig` CLI subcommand. Signed-off-by: Matt Moyer --- cmd/pinniped/cmd/get_kubeconfig.go | 346 -------------------- cmd/pinniped/cmd/get_kubeconfig_test.go | 399 ------------------------ go.mod | 1 - 3 files changed, 746 deletions(-) delete mode 100644 cmd/pinniped/cmd/get_kubeconfig.go delete mode 100644 cmd/pinniped/cmd/get_kubeconfig_test.go diff --git a/cmd/pinniped/cmd/get_kubeconfig.go b/cmd/pinniped/cmd/get_kubeconfig.go deleted file mode 100644 index 9ed9bacf..00000000 --- a/cmd/pinniped/cmd/get_kubeconfig.go +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bytes" - "context" - "encoding/base64" - "fmt" - "io" - "os" - "time" - - "github.com/ghodss/yaml" - "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" - v1 "k8s.io/client-go/tools/clientcmd/api/v1" - - configv1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/config/v1alpha1" - pinnipedclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" - "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/plog" -) - -//nolint: gochecknoinits -func init() { - rootCmd.AddCommand(newGetKubeConfigCommand().Command()) -} - -type getKubeConfigFlags struct { - token string - kubeconfig string - contextOverride string - namespace string - authenticatorName string - authenticatorType string -} - -type getKubeConfigCommand struct { - flags getKubeConfigFlags - // Test mocking points - getPathToSelf func() (string, error) - kubeClientCreator func(restConfig *rest.Config) (pinnipedclientset.Interface, error) -} - -func newGetKubeConfigCommand() *getKubeConfigCommand { - return &getKubeConfigCommand{ - flags: getKubeConfigFlags{ - namespace: "pinniped-concierge", - }, - getPathToSelf: os.Executable, - kubeClientCreator: func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedclientset.NewForConfig(restConfig) - }, - } -} - -func (c *getKubeConfigCommand) Command() *cobra.Command { - cmd := &cobra.Command{ - RunE: c.run, - Args: cobra.NoArgs, // do not accept positional arguments for this command - Use: "get-kubeconfig", - Short: "Print a kubeconfig for authenticating into a cluster via Pinniped", - Long: here.Doc(` - Print a kubeconfig for authenticating into a cluster via Pinniped. - - Requires admin-like access to the cluster using the current - kubeconfig context in order to access Pinniped's metadata. - The current kubeconfig is found similar to how kubectl finds it: - using the value of the --kubeconfig option, or if that is not - specified then from the value of the KUBECONFIG environment - variable, or if that is not specified then it defaults to - .kube/config in your home directory. - - Prints a kubeconfig which is suitable to access the cluster using - Pinniped as the authentication mechanism. This kubeconfig output - can be saved to a file and used with future kubectl commands, e.g.: - pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig - kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods - `), - } - cmd.Flags().StringVar(&c.flags.token, "token", "", "Credential to include in the resulting kubeconfig output (Required)") - cmd.Flags().StringVar(&c.flags.kubeconfig, "kubeconfig", c.flags.kubeconfig, "Path to the kubeconfig file") - cmd.Flags().StringVar(&c.flags.contextOverride, "kubeconfig-context", c.flags.contextOverride, "Kubeconfig context override") - cmd.Flags().StringVar(&c.flags.namespace, "pinniped-namespace", c.flags.namespace, "Namespace in which Pinniped was installed") - cmd.Flags().StringVar(&c.flags.authenticatorType, "authenticator-type", c.flags.authenticatorType, "Authenticator type (e.g., 'webhook', 'jwt')") - cmd.Flags().StringVar(&c.flags.authenticatorName, "authenticator-name", c.flags.authenticatorType, "Authenticator name") - mustMarkRequired(cmd, "token") - plog.RemoveKlogGlobalFlags() - return cmd -} - -func (c *getKubeConfigCommand) run(cmd *cobra.Command, args []string) error { - fullPathToSelf, err := c.getPathToSelf() - if err != nil { - return fmt.Errorf("could not find path to self: %w", err) - } - - clientConfig := newClientConfig(c.flags.kubeconfig, c.flags.contextOverride) - - currentKubeConfig, err := clientConfig.RawConfig() - if err != nil { - return err - } - - restConfig, err := clientConfig.ClientConfig() - if err != nil { - return err - } - clientset, err := c.kubeClientCreator(restConfig) - if err != nil { - return err - } - - authenticatorType, authenticatorName := c.flags.authenticatorType, c.flags.authenticatorName - if authenticatorType == "" || authenticatorName == "" { - authenticatorType, authenticatorName, err = getDefaultAuthenticator(clientset, c.flags.namespace) - if err != nil { - return err - } - } - - credentialIssuer, err := fetchPinnipedCredentialIssuer(clientset, c.flags.namespace) - if err != nil { - return err - } - - if credentialIssuer.Status.KubeConfigInfo == nil { - return constable.Error(`CredentialIssuer "pinniped-config" was missing KubeConfigInfo`) - } - - v1Cluster, err := copyCurrentClusterFromExistingKubeConfig(currentKubeConfig, c.flags.contextOverride) - if err != nil { - return err - } - - err = issueWarningForNonMatchingServerOrCA(v1Cluster, credentialIssuer, cmd.ErrOrStderr()) - if err != nil { - return err - } - - config := newPinnipedKubeconfig(v1Cluster, fullPathToSelf, c.flags.token, c.flags.namespace, authenticatorType, authenticatorName) - - err = writeConfigAsYAML(cmd.OutOrStdout(), config) - if err != nil { - return err - } - - return nil -} - -func issueWarningForNonMatchingServerOrCA(v1Cluster v1.Cluster, credentialIssuer *configv1alpha1.CredentialIssuer, warningsWriter io.Writer) error { - credentialIssuerCA, err := base64.StdEncoding.DecodeString(credentialIssuer.Status.KubeConfigInfo.CertificateAuthorityData) - if err != nil { - return err - } - if v1Cluster.Server != credentialIssuer.Status.KubeConfigInfo.Server || - !bytes.Equal(v1Cluster.CertificateAuthorityData, credentialIssuerCA) { - _, err := warningsWriter.Write([]byte("WARNING: Server and certificate authority did not match between local kubeconfig and Pinniped's CredentialIssuer on the cluster. Using local kubeconfig values.\n")) - if err != nil { - return fmt.Errorf("output write error: %w", err) - } - } - return nil -} - -type noAuthenticatorError struct{ Namespace string } - -func (e noAuthenticatorError) Error() string { - return fmt.Sprintf(`no authenticators were found in namespace %q`, e.Namespace) -} - -type indeterminateAuthenticatorError struct{ Namespace string } - -func (e indeterminateAuthenticatorError) Error() string { - return fmt.Sprintf( - `multiple authenticators were found in namespace %q, so --authenticator-name/--authenticator-type must be specified`, - e.Namespace, - ) -} - -func getDefaultAuthenticator(clientset pinnipedclientset.Interface, namespace string) (string, string, error) { - ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*20) - defer cancelFunc() - - webhooks, err := clientset.AuthenticationV1alpha1().WebhookAuthenticators(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return "", "", err - } - - type ref struct{ authenticatorType, authenticatorName string } - authenticators := make([]ref, 0, len(webhooks.Items)) - for _, webhook := range webhooks.Items { - authenticators = append(authenticators, ref{authenticatorType: "webhook", authenticatorName: webhook.Name}) - } - - if len(authenticators) == 0 { - return "", "", noAuthenticatorError{namespace} - } - if len(authenticators) > 1 { - return "", "", indeterminateAuthenticatorError{namespace} - } - return authenticators[0].authenticatorType, authenticators[0].authenticatorName, nil -} - -func fetchPinnipedCredentialIssuer(clientset pinnipedclientset.Interface, pinnipedInstallationNamespace string) (*configv1alpha1.CredentialIssuer, error) { - ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*20) - defer cancelFunc() - - credentialIssuers, err := clientset.ConfigV1alpha1().CredentialIssuers(pinnipedInstallationNamespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, err - } - - if len(credentialIssuers.Items) == 0 { - return nil, constable.Error(fmt.Sprintf( - `No CredentialIssuer was found in namespace "%s". Is Pinniped installed on this cluster in namespace "%s"?`, - pinnipedInstallationNamespace, - pinnipedInstallationNamespace, - )) - } - - if len(credentialIssuers.Items) > 1 { - return nil, constable.Error(fmt.Sprintf( - `More than one CredentialIssuer was found in namespace "%s"`, - pinnipedInstallationNamespace, - )) - } - - return &credentialIssuers.Items[0], nil -} - -func newClientConfig(kubeconfigPathOverride string, currentContextName string) clientcmd.ClientConfig { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - loadingRules.ExplicitPath = kubeconfigPathOverride - clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{ - CurrentContext: currentContextName, - }) - return clientConfig -} - -func writeConfigAsYAML(outputWriter io.Writer, config v1.Config) error { - output, err := yaml.Marshal(&config) - if err != nil { - return fmt.Errorf("YAML serialization error: %w", err) - } - - _, err = outputWriter.Write(output) - if err != nil { - return fmt.Errorf("output write error: %w", err) - } - - return nil -} - -func copyCurrentClusterFromExistingKubeConfig(currentKubeConfig clientcmdapi.Config, currentContextNameOverride string) (v1.Cluster, error) { - v1Cluster := v1.Cluster{} - - contextName := currentKubeConfig.CurrentContext - if currentContextNameOverride != "" { - contextName = currentContextNameOverride - } - - err := v1.Convert_api_Cluster_To_v1_Cluster( - currentKubeConfig.Clusters[currentKubeConfig.Contexts[contextName].Cluster], - &v1Cluster, - nil, - ) - if err != nil { - return v1.Cluster{}, err - } - - return v1Cluster, nil -} - -func newPinnipedKubeconfig(v1Cluster v1.Cluster, fullPathToSelf string, token string, namespace string, authenticatorType string, authenticatorName string) v1.Config { - clusterName := "pinniped-cluster" - userName := "pinniped-user" - - return v1.Config{ - Kind: "Config", - APIVersion: v1.SchemeGroupVersion.Version, - Preferences: v1.Preferences{}, - Clusters: []v1.NamedCluster{ - { - Name: clusterName, - Cluster: v1Cluster, - }, - }, - Contexts: []v1.NamedContext{ - { - Name: clusterName, - Context: v1.Context{ - Cluster: clusterName, - AuthInfo: userName, - }, - }, - }, - AuthInfos: []v1.NamedAuthInfo{ - { - Name: userName, - AuthInfo: v1.AuthInfo{ - Exec: &v1.ExecConfig{ - Command: fullPathToSelf, - Args: []string{"exchange-credential"}, - Env: []v1.ExecEnvVar{ - { - Name: "PINNIPED_K8S_API_ENDPOINT", - Value: v1Cluster.Server, - }, - { - Name: "PINNIPED_CA_BUNDLE", - Value: string(v1Cluster.CertificateAuthorityData)}, - { - Name: "PINNIPED_NAMESPACE", - Value: namespace, - }, - { - Name: "PINNIPED_TOKEN", - Value: token, - }, - { - Name: "PINNIPED_AUTHENTICATOR_TYPE", - Value: authenticatorType, - }, - { - Name: "PINNIPED_AUTHENTICATOR_NAME", - Value: authenticatorName, - }, - }, - APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), - InstallHint: "The Pinniped CLI is required to authenticate to the current cluster.\n" + - "For more information, please visit https://pinniped.dev", - }, - }, - }, - }, - CurrentContext: clusterName, - } -} diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go deleted file mode 100644 index 40a88b90..00000000 --- a/cmd/pinniped/cmd/get_kubeconfig_test.go +++ /dev/null @@ -1,399 +0,0 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bytes" - "encoding/base64" - "fmt" - "strings" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/rest" - coretesting "k8s.io/client-go/testing" - - authv1alpha "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" - configv1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/config/v1alpha1" - pinnipedclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" - pinnipedfake "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned/fake" - "go.pinniped.dev/internal/here" -) - -var ( - knownGoodUsageForGetKubeConfig = here.Doc(` - Usage: - get-kubeconfig [flags] - - Flags: - --authenticator-name string Authenticator name - --authenticator-type string Authenticator type (e.g., 'webhook', 'jwt') - -h, --help help for get-kubeconfig - --kubeconfig string Path to the kubeconfig file - --kubeconfig-context string Kubeconfig context override - --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped-concierge") - --token string Credential to include in the resulting kubeconfig output (Required) - - `) - - knownGoodHelpForGetKubeConfig = here.Doc(` - Print a kubeconfig for authenticating into a cluster via Pinniped. - - Requires admin-like access to the cluster using the current - kubeconfig context in order to access Pinniped's metadata. - The current kubeconfig is found similar to how kubectl finds it: - using the value of the --kubeconfig option, or if that is not - specified then from the value of the KUBECONFIG environment - variable, or if that is not specified then it defaults to - .kube/config in your home directory. - - Prints a kubeconfig which is suitable to access the cluster using - Pinniped as the authentication mechanism. This kubeconfig output - can be saved to a file and used with future kubectl commands, e.g.: - pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig - kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods - - Usage: - get-kubeconfig [flags] - - Flags: - --authenticator-name string Authenticator name - --authenticator-type string Authenticator type (e.g., 'webhook', 'jwt') - -h, --help help for get-kubeconfig - --kubeconfig string Path to the kubeconfig file - --kubeconfig-context string Kubeconfig context override - --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped-concierge") - --token string Credential to include in the resulting kubeconfig output (Required) - `) -) - -func TestNewGetKubeConfigCmd(t *testing.T) { - tests := []struct { - name string - args []string - wantError bool - wantStdout string - wantStderr string - }{ - { - name: "help flag passed", - args: []string{"--help"}, - wantStdout: knownGoodHelpForGetKubeConfig, - }, - { - name: "missing required flag", - args: []string{}, - wantError: true, - wantStdout: `Error: required flag(s) "token" not set` + "\n" + knownGoodUsageForGetKubeConfig, - }, - } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - cmd := newGetKubeConfigCommand().Command() - require.NotNil(t, cmd) - - var stdout, stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs(tt.args) - err := cmd.Execute() - if tt.wantError { - require.Error(t, err) - } else { - require.NoError(t, err) - } - require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") - require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") - }) - } -} - -type expectedKubeconfigYAML struct { - clusterCAData string - clusterServer string - command string - token string - pinnipedEndpoint string - pinnipedCABundle string - namespace string - authenticatorType string - authenticatorName string -} - -func (e expectedKubeconfigYAML) String() string { - return here.Docf(` - apiVersion: v1 - clusters: - - cluster: - certificate-authority-data: %s - server: %s - name: pinniped-cluster - contexts: - - context: - cluster: pinniped-cluster - user: pinniped-user - name: pinniped-cluster - current-context: pinniped-cluster - kind: Config - preferences: {} - users: - - name: pinniped-user - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - args: - - exchange-credential - command: %s - env: - - name: PINNIPED_K8S_API_ENDPOINT - value: %s - - name: PINNIPED_CA_BUNDLE - value: %s - - name: PINNIPED_NAMESPACE - value: %s - - name: PINNIPED_TOKEN - value: %s - - name: PINNIPED_AUTHENTICATOR_TYPE - value: %s - - name: PINNIPED_AUTHENTICATOR_NAME - value: %s - installHint: |- - The Pinniped CLI is required to authenticate to the current cluster. - For more information, please visit https://pinniped.dev - `, e.clusterCAData, e.clusterServer, e.command, e.pinnipedEndpoint, e.pinnipedCABundle, e.namespace, e.token, e.authenticatorType, e.authenticatorName) -} - -func newCredentialIssuer(name, namespace, server, certificateAuthorityData string) *configv1alpha1.CredentialIssuer { - return &configv1alpha1.CredentialIssuer{ - TypeMeta: metav1.TypeMeta{ - Kind: "CredentialIssuer", - APIVersion: configv1alpha1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Status: configv1alpha1.CredentialIssuerStatus{ - KubeConfigInfo: &configv1alpha1.CredentialIssuerKubeConfigInfo{ - Server: server, - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(certificateAuthorityData)), - }, - }, - } -} - -func TestRun(t *testing.T) { - t.Parallel() - tests := []struct { - name string - mocks func(*getKubeConfigCommand) - wantError string - wantStdout string - wantStderr string - }{ - { - name: "failure to get path to self", - mocks: func(cmd *getKubeConfigCommand) { - cmd.getPathToSelf = func() (string, error) { - return "", fmt.Errorf("some error getting path to self") - } - }, - wantError: "could not find path to self: some error getting path to self", - }, - { - name: "kubeconfig does not exist", - mocks: func(cmd *getKubeConfigCommand) { - cmd.flags.kubeconfig = "./testdata/does-not-exist.yaml" - }, - wantError: "stat ./testdata/does-not-exist.yaml: no such file or directory", - }, - { - name: "fail to get client", - mocks: func(cmd *getKubeConfigCommand) { - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return nil, fmt.Errorf("some error configuring clientset") - } - }, - wantError: "some error configuring clientset", - }, - { - name: "fail to get authenticators", - mocks: func(cmd *getKubeConfigCommand) { - cmd.flags.authenticatorName = "" - cmd.flags.authenticatorType = "" - clientset := pinnipedfake.NewSimpleClientset() - clientset.PrependReactor("*", "*", func(_ coretesting.Action) (bool, runtime.Object, error) { - return true, nil, fmt.Errorf("some error getting authenticators") - }) - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return clientset, nil - } - }, - wantError: "some error getting authenticators", - }, - { - name: "zero authenticators", - mocks: func(cmd *getKubeConfigCommand) { - cmd.flags.authenticatorName = "" - cmd.flags.authenticatorType = "" - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset(), nil - } - }, - wantError: `no authenticators were found in namespace "test-namespace"`, - }, - { - name: "multiple authenticators", - mocks: func(cmd *getKubeConfigCommand) { - cmd.flags.authenticatorName = "" - cmd.flags.authenticatorType = "" - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset( - &authv1alpha.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Namespace: "test-namespace", Name: "webhook-one"}}, - &authv1alpha.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Namespace: "test-namespace", Name: "webhook-two"}}, - ), nil - } - }, - wantError: `multiple authenticators were found in namespace "test-namespace", so --authenticator-name/--authenticator-type must be specified`, - }, - { - name: "fail to get CredentialIssuers", - mocks: func(cmd *getKubeConfigCommand) { - clientset := pinnipedfake.NewSimpleClientset() - clientset.PrependReactor("*", "*", func(_ coretesting.Action) (bool, runtime.Object, error) { - return true, nil, fmt.Errorf("some error getting CredentialIssuers") - }) - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return clientset, nil - } - }, - wantError: "some error getting CredentialIssuers", - }, - { - name: "zero CredentialIssuers found", - mocks: func(cmd *getKubeConfigCommand) { - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset( - newCredentialIssuer("pinniped-config-1", "not-the-test-namespace", "", ""), - ), nil - } - }, - wantError: `No CredentialIssuer was found in namespace "test-namespace". Is Pinniped installed on this cluster in namespace "test-namespace"?`, - }, - { - name: "multiple CredentialIssuers found", - mocks: func(cmd *getKubeConfigCommand) { - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset( - newCredentialIssuer("pinniped-config-1", "test-namespace", "", ""), - newCredentialIssuer("pinniped-config-2", "test-namespace", "", ""), - ), nil - } - }, - wantError: `More than one CredentialIssuer was found in namespace "test-namespace"`, - }, - { - name: "CredentialIssuer missing KubeConfigInfo", - mocks: func(cmd *getKubeConfigCommand) { - ci := newCredentialIssuer("pinniped-config", "test-namespace", "", "") - ci.Status.KubeConfigInfo = nil - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset(ci), nil - } - }, - wantError: `CredentialIssuer "pinniped-config" was missing KubeConfigInfo`, - }, - { - name: "KubeConfigInfo has invalid base64", - mocks: func(cmd *getKubeConfigCommand) { - ci := newCredentialIssuer("pinniped-config", "test-namespace", "https://example.com", "") - ci.Status.KubeConfigInfo.CertificateAuthorityData = "invalid-base64-test-ca" - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset(ci), nil - } - }, - wantError: `illegal base64 data at input byte 7`, - }, - { - name: "success using remote CA data", - mocks: func(cmd *getKubeConfigCommand) { - ci := newCredentialIssuer("pinniped-config", "test-namespace", "https://fake-server-url-value", "fake-certificate-authority-data-value") - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset(ci), nil - } - }, - wantStdout: expectedKubeconfigYAML{ - clusterCAData: "ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==", - clusterServer: "https://fake-server-url-value", - command: "/path/to/pinniped", - token: "test-token", - pinnipedEndpoint: "https://fake-server-url-value", - pinnipedCABundle: "fake-certificate-authority-data-value", - namespace: "test-namespace", - authenticatorType: "test-authenticator-type", - authenticatorName: "test-authenticator-name", - }.String(), - }, - { - name: "success using local CA data and discovered authenticator", - mocks: func(cmd *getKubeConfigCommand) { - cmd.flags.authenticatorName = "" - cmd.flags.authenticatorType = "" - - cmd.kubeClientCreator = func(_ *rest.Config) (pinnipedclientset.Interface, error) { - return pinnipedfake.NewSimpleClientset( - &authv1alpha.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Namespace: "test-namespace", Name: "discovered-authenticator"}}, - newCredentialIssuer("pinniped-config", "test-namespace", "https://example.com", "test-ca"), - ), nil - } - }, - wantStderr: `WARNING: Server and certificate authority did not match between local kubeconfig and Pinniped's CredentialIssuer on the cluster. Using local kubeconfig values.`, - wantStdout: expectedKubeconfigYAML{ - clusterCAData: "ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==", - clusterServer: "https://fake-server-url-value", - command: "/path/to/pinniped", - token: "test-token", - pinnipedEndpoint: "https://fake-server-url-value", - pinnipedCABundle: "fake-certificate-authority-data-value", - namespace: "test-namespace", - authenticatorType: "webhook", - authenticatorName: "discovered-authenticator", - }.String(), - }, - } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // Start with a default getKubeConfigCommand, set some defaults, then apply any mocks. - c := newGetKubeConfigCommand() - c.flags.token = "test-token" - c.flags.namespace = "test-namespace" - c.flags.authenticatorName = "test-authenticator-name" - c.flags.authenticatorType = "test-authenticator-type" - c.getPathToSelf = func() (string, error) { return "/path/to/pinniped", nil } - c.flags.kubeconfig = "./testdata/kubeconfig.yaml" - tt.mocks(c) - - cmd := &cobra.Command{} - var stdout, stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs([]string{}) - err := c.run(cmd, []string{}) - if tt.wantError != "" { - require.EqualError(t, err, tt.wantError) - } else { - require.NoError(t, err) - } - require.Equal(t, strings.TrimSpace(tt.wantStdout), strings.TrimSpace(stdout.String()), "unexpected stdout") - require.Equal(t, strings.TrimSpace(tt.wantStderr), strings.TrimSpace(stderr.String()), "unexpected stderr") - }) - } -} diff --git a/go.mod b/go.mod index 84237fd7..9d3d98c6 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/coreos/go-oidc v2.2.1+incompatible github.com/davecgh/go-spew v1.1.1 - github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v0.2.1 github.com/go-logr/stdr v0.2.0 github.com/gofrs/flock v0.8.0 From 71850419c17a698e9f483c62c64079c403a157ee Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Mon, 14 Dec 2020 18:38:19 -0600 Subject: [PATCH 04/16] Overhaul `pinniped` CLI subcommands. - Adds two new subcommands: `pinniped get kubeconfig` and `pinniped login static` - Adds concierge support to `pinniped login oidc`. - Adds back wrapper commands for the now deprecated `pinniped get-kubeconfig` and `pinniped exchange-credential` commands. These now wrap `pinniped get kubeconfig` and `pinniped login static` respectively. Signed-off-by: Matt Moyer --- cmd/pinniped/cmd/deprecated.go | 136 +++++++ cmd/pinniped/cmd/get.go | 20 + cmd/pinniped/cmd/kubeconfig.go | 364 ++++++++++++++++++ cmd/pinniped/cmd/kubeconfig_test.go | 524 ++++++++++++++++++++++++++ cmd/pinniped/cmd/login.go | 3 +- cmd/pinniped/cmd/login_oidc.go | 261 ++++++++----- cmd/pinniped/cmd/login_oidc_test.go | 176 +++++++-- cmd/pinniped/cmd/login_static.go | 120 ++++++ cmd/pinniped/cmd/login_static_test.go | 180 +++++++++ 9 files changed, 1661 insertions(+), 123 deletions(-) create mode 100644 cmd/pinniped/cmd/deprecated.go create mode 100644 cmd/pinniped/cmd/get.go create mode 100644 cmd/pinniped/cmd/kubeconfig.go create mode 100644 cmd/pinniped/cmd/kubeconfig_test.go create mode 100644 cmd/pinniped/cmd/login_static.go create mode 100644 cmd/pinniped/cmd/login_static_test.go diff --git a/cmd/pinniped/cmd/deprecated.go b/cmd/pinniped/cmd/deprecated.go new file mode 100644 index 00000000..8894eef4 --- /dev/null +++ b/cmd/pinniped/cmd/deprecated.go @@ -0,0 +1,136 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/plog" +) + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(legacyGetKubeconfigCommand(kubeconfigRealDeps())) + rootCmd.AddCommand(legacyExchangeTokenCommand(staticLoginRealDeps())) +} + +func legacyGetKubeconfigCommand(deps kubeconfigDeps) *cobra.Command { + var ( + cmd = &cobra.Command{ + Hidden: true, + Deprecated: "Please use `pinniped get kubeconfig` instead.", + + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "get-kubeconfig", + Short: "Print a kubeconfig for authenticating into a cluster via Pinniped", + Long: here.Doc(` + Print a kubeconfig for authenticating into a cluster via Pinniped. + Requires admin-like access to the cluster using the current + kubeconfig context in order to access Pinniped's metadata. + The current kubeconfig is found similar to how kubectl finds it: + using the value of the --kubeconfig option, or if that is not + specified then from the value of the KUBECONFIG environment + variable, or if that is not specified then it defaults to + .kube/config in your home directory. + Prints a kubeconfig which is suitable to access the cluster using + Pinniped as the authentication mechanism. This kubeconfig output + can be saved to a file and used with future kubectl commands, e.g.: + pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig + kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods + `), + } + token string + kubeconfig string + contextOverride string + namespace string + authenticatorType string + authenticatorName string + ) + + cmd.Flags().StringVar(&token, "token", "", "Credential to include in the resulting kubeconfig output (Required)") + cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to the kubeconfig file") + cmd.Flags().StringVar(&contextOverride, "kubeconfig-context", "", "Kubeconfig context override") + cmd.Flags().StringVar(&namespace, "pinniped-namespace", "pinniped-concierge", "Namespace in which Pinniped was installed") + cmd.Flags().StringVar(&authenticatorType, "authenticator-type", "", "Authenticator type (e.g., 'webhook', 'jwt')") + cmd.Flags().StringVar(&authenticatorName, "authenticator-name", "", "Authenticator name") + mustMarkRequired(cmd, "token") + plog.RemoveKlogGlobalFlags() + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runGetKubeconfig(cmd.OutOrStdout(), deps, getKubeconfigParams{ + kubeconfigPath: kubeconfig, + kubeconfigContextOverride: contextOverride, + staticToken: token, + concierge: getKubeconfigConciergeParams{ + namespace: namespace, + authenticatorName: authenticatorName, + authenticatorType: authenticatorType, + }, + }) + } + return cmd +} + +func legacyExchangeTokenCommand(deps staticLoginDeps) *cobra.Command { + cmd := &cobra.Command{ + Hidden: true, + Deprecated: "Please use `pinniped login static` instead.", + + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "exchange-credential", + Short: "Exchange a credential for a cluster-specific access credential", + Long: here.Doc(` + Exchange a credential which proves your identity for a time-limited, + cluster-specific access credential. + Designed to be conveniently used as an credential plugin for kubectl. + See the help message for 'pinniped get-kubeconfig' for more + information about setting up a kubeconfig file using Pinniped. + Requires all of the following environment variables, which are + typically set in the kubeconfig: + - PINNIPED_TOKEN: the token to send to Pinniped for exchange + - PINNIPED_NAMESPACE: the namespace of the authenticator to authenticate + against + - PINNIPED_AUTHENTICATOR_TYPE: the type of authenticator to authenticate + against (e.g., "webhook", "jwt") + - PINNIPED_AUTHENTICATOR_NAME: the name of the authenticator to authenticate + against + - PINNIPED_CA_BUNDLE: the CA bundle to trust when calling + Pinniped's HTTPS endpoint + - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential + exchange API + For more information about credential plugins in general, see + https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins + `), + } + plog.RemoveKlogGlobalFlags() + cmd.RunE = func(cmd *cobra.Command, args []string) error { + // Make a little helper to grab OS environment variables and keep a list that were missing. + var missing []string + getEnv := func(name string) string { + value, ok := os.LookupEnv(name) + if !ok { + missing = append(missing, name) + } + return value + } + flags := staticLoginParams{ + staticToken: getEnv("PINNIPED_TOKEN"), + conciergeEnabled: true, + conciergeNamespace: getEnv("PINNIPED_NAMESPACE"), + conciergeAuthenticatorType: getEnv("PINNIPED_AUTHENTICATOR_TYPE"), + conciergeAuthenticatorName: getEnv("PINNIPED_AUTHENTICATOR_NAME"), + conciergeEndpoint: getEnv("PINNIPED_K8S_API_ENDPOINT"), + conciergeCABundle: base64.StdEncoding.EncodeToString([]byte(getEnv("PINNIPED_CA_BUNDLE"))), + } + if len(missing) > 0 { + return fmt.Errorf("failed to get credential: required environment variable(s) not set: %v", missing) + } + return runStaticLogin(cmd.OutOrStdout(), deps, flags) + } + return cmd +} diff --git a/cmd/pinniped/cmd/get.go b/cmd/pinniped/cmd/get.go new file mode 100644 index 00000000..27a027ae --- /dev/null +++ b/cmd/pinniped/cmd/get.go @@ -0,0 +1,20 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +//nolint: gochecknoglobals +var getCmd = &cobra.Command{ + Use: "get", + Short: "get", + SilenceUsage: true, // do not print usage message when commands fail +} + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(getCmd) +} diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go new file mode 100644 index 00000000..5bf8efe8 --- /dev/null +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -0,0 +1,364 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "os" + "strconv" + "strings" + "time" + + "github.com/coreos/go-oidc" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + _ "k8s.io/client-go/plugin/pkg/client/auth" // Adds handlers for various dynamic auth plugins in client-go + + conciergev1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" +) + +type kubeconfigDeps struct { + getPathToSelf func() (string, error) + getClientset func(clientcmd.ClientConfig) (conciergeclientset.Interface, error) +} + +func kubeconfigRealDeps() kubeconfigDeps { + return kubeconfigDeps{ + getPathToSelf: os.Executable, + getClientset: func(clientConfig clientcmd.ClientConfig) (conciergeclientset.Interface, error) { + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + return conciergeclientset.NewForConfig(restConfig) + }, + } +} + +//nolint: gochecknoinits +func init() { + getCmd.AddCommand(kubeconfigCommand(kubeconfigRealDeps())) +} + +type getKubeconfigOIDCParams struct { + issuer string + clientID string + listenPort uint16 + scopes []string + skipBrowser bool + sessionCachePath string + debugSessionCache bool + caBundlePaths []string + requestAudience string +} + +type getKubeconfigConciergeParams struct { + disabled bool + namespace string + authenticatorName string + authenticatorType string +} + +type getKubeconfigParams struct { + kubeconfigPath string + kubeconfigContextOverride string + staticToken string + staticTokenEnvName string + oidc getKubeconfigOIDCParams + concierge getKubeconfigConciergeParams +} + +func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { + var ( + cmd = cobra.Command{ + Args: cobra.NoArgs, + Use: "kubeconfig", + Short: "Generate a Pinniped-based kubeconfig for a cluster", + SilenceUsage: true, + } + flags getKubeconfigParams + ) + + f := cmd.Flags() + f.StringVar(&flags.staticToken, "static-token", "", "Instead of doing an OIDC-based login, specify a static token") + f.StringVar(&flags.staticTokenEnvName, "static-token-env", "", "Instead of doing an OIDC-based login, read a static token from the environment") + + f.BoolVar(&flags.concierge.disabled, "no-concierge", false, "Generate a configuration which does not use the concierge, but sends the credential to the cluster directly") + f.StringVar(&flags.concierge.namespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") + f.StringVar(&flags.concierge.authenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover)") + f.StringVar(&flags.concierge.authenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name (default: autodiscover)") + + f.StringVar(&flags.oidc.issuer, "oidc-issuer", "", "OpenID Connect issuer URL (default: autodiscover)") + f.StringVar(&flags.oidc.clientID, "oidc-client-id", "pinniped-cli", "OpenID Connect client ID (default: autodiscover)") + f.Uint16Var(&flags.oidc.listenPort, "oidc-listen-port", 0, "TCP port for localhost listener (authorization code flow only)") + f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped.sts.unrestricted"}, "OpenID Connect scopes to request during login") + f.BoolVar(&flags.oidc.skipBrowser, "oidc-skip-browser", false, "During OpenID Connect login, skip opening the browser (just print the URL)") + f.StringVar(&flags.oidc.sessionCachePath, "oidc-session-cache", "", "Path to OpenID Connect session cache file") + f.StringSliceVar(&flags.oidc.caBundlePaths, "oidc-ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)") + f.BoolVar(&flags.oidc.debugSessionCache, "oidc-debug-session-cache", false, "Print debug logs related to the OpenID Connect session cache") + f.StringVar(&flags.oidc.requestAudience, "oidc-request-audience", "", "Request a token with an alternate audience using RF8693 token exchange") + f.StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file") + f.StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)") + + mustMarkHidden(&cmd, "oidc-debug-session-cache") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { return runGetKubeconfig(cmd.OutOrStdout(), deps, flags) } + return &cmd +} + +func runGetKubeconfig(out io.Writer, deps kubeconfigDeps, flags getKubeconfigParams) error { + execConfig := clientcmdapi.ExecConfig{ + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + Args: []string{}, + Env: []clientcmdapi.ExecEnvVar{}, + } + + var err error + execConfig.Command, err = deps.getPathToSelf() + if err != nil { + return fmt.Errorf("could not determine the Pinniped executable path: %w", err) + } + + oidcCABundle, err := loadCABundlePaths(flags.oidc.caBundlePaths) + if err != nil { + return fmt.Errorf("could not read --oidc-ca-bundle: %w", err) + } + + clientConfig := newClientConfig(flags.kubeconfigPath, flags.kubeconfigContextOverride) + currentKubeConfig, err := clientConfig.RawConfig() + if err != nil { + return fmt.Errorf("could not load --kubeconfig: %w", err) + } + cluster, err := copyCurrentClusterFromExistingKubeConfig(currentKubeConfig, flags.kubeconfigContextOverride) + if err != nil { + return fmt.Errorf("could not load --kubeconfig/--kubeconfig-context: %w", err) + } + clientset, err := deps.getClientset(clientConfig) + if err != nil { + return fmt.Errorf("could not configure Kubernetes client: %w", err) + } + + if !flags.concierge.disabled { + authenticator, err := lookupAuthenticator( + clientset, + flags.concierge.namespace, + flags.concierge.authenticatorType, + flags.concierge.authenticatorName, + ) + if err != nil { + return err + } + if err := configureConcierge(authenticator, &flags, cluster, &oidcCABundle, &execConfig); err != nil { + return err + } + } + + // If one of the --static-* flags was passed, output a config that runs `pinniped login static`. + if flags.staticToken != "" || flags.staticTokenEnvName != "" { + if flags.staticToken != "" && flags.staticTokenEnvName != "" { + return fmt.Errorf("only one of --static-token and --static-token-env can be specified") + } + execConfig.Args = append([]string{"login", "static"}, execConfig.Args...) + if flags.staticToken != "" { + execConfig.Args = append(execConfig.Args, "--token="+flags.staticToken) + } + if flags.staticTokenEnvName != "" { + execConfig.Args = append(execConfig.Args, "--token-env="+flags.staticTokenEnvName) + } + return writeConfigAsYAML(out, newExecKubeconfig(cluster, &execConfig)) + } + + // Otherwise continue to parse the OIDC-related flags and output a config that runs `pinniped login oidc`. + execConfig.Args = append([]string{"login", "oidc"}, execConfig.Args...) + if flags.oidc.issuer == "" { + return fmt.Errorf("could not autodiscover --oidc-issuer, and none was provided") + } + execConfig.Args = append(execConfig.Args, + "--issuer="+flags.oidc.issuer, + "--client-id="+flags.oidc.clientID, + "--scopes="+strings.Join(flags.oidc.scopes, ","), + ) + if flags.oidc.skipBrowser { + execConfig.Args = append(execConfig.Args, "--skip-browser") + } + if flags.oidc.listenPort != 0 { + execConfig.Args = append(execConfig.Args, "--listen-port="+strconv.Itoa(int(flags.oidc.listenPort))) + } + if oidcCABundle != "" { + execConfig.Args = append(execConfig.Args, "--ca-bundle-data="+base64.StdEncoding.EncodeToString([]byte(oidcCABundle))) + } + if flags.oidc.sessionCachePath != "" { + execConfig.Args = append(execConfig.Args, "--session-cache="+flags.oidc.sessionCachePath) + } + if flags.oidc.debugSessionCache { + execConfig.Args = append(execConfig.Args, "--debug-session-cache") + } + if flags.oidc.requestAudience != "" { + execConfig.Args = append(execConfig.Args, "--request-audience="+flags.oidc.requestAudience) + } + return writeConfigAsYAML(out, newExecKubeconfig(cluster, &execConfig)) +} + +func configureConcierge(authenticator metav1.Object, flags *getKubeconfigParams, v1Cluster *clientcmdapi.Cluster, oidcCABundle *string, execConfig *clientcmdapi.ExecConfig) error { + switch auth := authenticator.(type) { + case *conciergev1alpha1.WebhookAuthenticator: + // If the --concierge-authenticator-type/--concierge-authenticator-name flags were not set explicitly, set + // them to point at the discovered WebhookAuthenticator. + if flags.concierge.authenticatorType == "" && flags.concierge.authenticatorName == "" { + flags.concierge.authenticatorType = "webhook" + flags.concierge.authenticatorName = auth.Name + } + case *conciergev1alpha1.JWTAuthenticator: + // If the --concierge-authenticator-type/--concierge-authenticator-name flags were not set explicitly, set + // them to point at the discovered JWTAuthenticator. + if flags.concierge.authenticatorType == "" && flags.concierge.authenticatorName == "" { + flags.concierge.authenticatorType = "jwt" + flags.concierge.authenticatorName = auth.Name + } + + // If the --oidc-issuer flag was not set explicitly, default it to the spec.issuer field of the JWTAuthenticator. + if flags.oidc.issuer == "" { + flags.oidc.issuer = auth.Spec.Issuer + } + + // If the --oidc-request-audience flag was not set explicitly, default it to the spec.audience field of the JWTAuthenticator. + if flags.oidc.requestAudience == "" { + flags.oidc.requestAudience = auth.Spec.Audience + } + + // If the --oidc-ca-bundle flags was not set explicitly, default it to the + // spec.tls.certificateAuthorityData field of the JWTAuthenticator. + if *oidcCABundle == "" && auth.Spec.TLS != nil && auth.Spec.TLS.CertificateAuthorityData != "" { + decoded, err := base64.StdEncoding.DecodeString(auth.Spec.TLS.CertificateAuthorityData) + if err != nil { + return fmt.Errorf("tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator %s/%s has invalid spec.tls.certificateAuthorityData: %w", auth.Namespace, auth.Name, err) + } + *oidcCABundle = string(decoded) + } + } + + // Append the flags to configure the Concierge credential exchange at runtime. + execConfig.Args = append(execConfig.Args, + "--enable-concierge", + "--concierge-namespace="+flags.concierge.namespace, + "--concierge-authenticator-name="+flags.concierge.authenticatorName, + "--concierge-authenticator-type="+flags.concierge.authenticatorType, + "--concierge-endpoint="+v1Cluster.Server, + "--concierge-ca-bundle-data="+base64.StdEncoding.EncodeToString(v1Cluster.CertificateAuthorityData), + ) + return nil +} + +func loadCABundlePaths(paths []string) (string, error) { + if len(paths) == 0 { + return "", nil + } + blobs := make([][]byte, 0, len(paths)) + for _, p := range paths { + pem, err := ioutil.ReadFile(p) + if err != nil { + return "", err + } + blobs = append(blobs, pem) + } + return string(bytes.Join(blobs, []byte("\n"))), nil +} + +func newExecKubeconfig(cluster *clientcmdapi.Cluster, execConfig *clientcmdapi.ExecConfig) clientcmdapi.Config { + const name = "pinniped" + return clientcmdapi.Config{ + Kind: "Config", + APIVersion: clientcmdapi.SchemeGroupVersion.Version, + Clusters: map[string]*clientcmdapi.Cluster{name: cluster}, + AuthInfos: map[string]*clientcmdapi.AuthInfo{name: {Exec: execConfig}}, + Contexts: map[string]*clientcmdapi.Context{name: {Cluster: name, AuthInfo: name}}, + CurrentContext: name, + } +} + +func lookupAuthenticator(clientset conciergeclientset.Interface, namespace, authType, authName string) (metav1.Object, error) { + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*20) + defer cancelFunc() + + // If one was specified, look it up or error. + if authName != "" && authType != "" { + switch strings.ToLower(authType) { + case "webhook": + return clientset.AuthenticationV1alpha1().WebhookAuthenticators(namespace).Get(ctx, authName, metav1.GetOptions{}) + case "jwt": + return clientset.AuthenticationV1alpha1().JWTAuthenticators(namespace).Get(ctx, authName, metav1.GetOptions{}) + default: + return nil, fmt.Errorf(`invalid authenticator type %q, supported values are "webhook" and "jwt"`, authType) + } + } + + // Otherwise list all the available authenticators and hope there's just a single one. + + jwtAuths, err := clientset.AuthenticationV1alpha1().JWTAuthenticators(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list JWTAuthenticator objects for autodiscovery: %w", err) + } + webhooks, err := clientset.AuthenticationV1alpha1().WebhookAuthenticators(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list WebhookAuthenticator objects for autodiscovery: %w", err) + } + + results := make([]metav1.Object, 0, len(jwtAuths.Items)+len(webhooks.Items)) + for i := range jwtAuths.Items { + results = append(results, &jwtAuths.Items[i]) + } + for i := range webhooks.Items { + results = append(results, &webhooks.Items[i]) + } + if len(results) == 0 { + return nil, fmt.Errorf("no authenticators were found in namespace %q (try setting --concierge-namespace)", namespace) + } + if len(results) > 1 { + return nil, fmt.Errorf("multiple authenticators were found in namespace %q, so the --concierge-authenticator-type/--concierge-authenticator-name flags must be specified", namespace) + } + return results[0], nil +} + +func newClientConfig(kubeconfigPathOverride string, currentContextName string) clientcmd.ClientConfig { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeconfigPathOverride + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{ + CurrentContext: currentContextName, + }) + return clientConfig +} + +func writeConfigAsYAML(out io.Writer, config clientcmdapi.Config) error { + output, err := clientcmd.Write(config) + if err != nil { + return err + } + _, err = out.Write(output) + if err != nil { + return fmt.Errorf("could not write output: %w", err) + } + return nil +} + +func copyCurrentClusterFromExistingKubeConfig(currentKubeConfig clientcmdapi.Config, currentContextNameOverride string) (*clientcmdapi.Cluster, error) { + contextName := currentKubeConfig.CurrentContext + if currentContextNameOverride != "" { + contextName = currentContextNameOverride + } + context := currentKubeConfig.Contexts[contextName] + if context == nil { + return nil, fmt.Errorf("no such context %q", contextName) + } + return currentKubeConfig.Clusters[context.Cluster], nil +} diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go new file mode 100644 index 00000000..739df951 --- /dev/null +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -0,0 +1,524 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "io/ioutil" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kubetesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/clientcmd" + + conciergev1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" + fakeconciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned/fake" + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" +) + +func TestGetKubeconfig(t *testing.T) { + testCA, err := certauthority.New(pkix.Name{CommonName: "Test CA"}, 1*time.Hour) + require.NoError(t, err) + tmpdir := testutil.TempDir(t) + testCABundlePath := filepath.Join(tmpdir, "testca.pem") + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCA.Bundle(), 0600)) + + tests := []struct { + name string + args []string + env map[string]string + getPathToSelfErr error + getClientsetErr error + conciergeObjects []runtime.Object + conciergeReactions []kubetesting.Reactor + wantError bool + wantStdout string + wantStderr string + wantOptionsCount int + }{ + { + name: "help flag passed", + args: []string{"--help"}, + wantStdout: here.Doc(` + Generate a Pinniped-based kubeconfig for a cluster + + Usage: + kubeconfig [flags] + + Flags: + --concierge-authenticator-name string Concierge authenticator name (default: autodiscover) + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover) + --concierge-namespace string Namespace in which the concierge was installed (default "pinniped-concierge") + -h, --help help for kubeconfig + --kubeconfig string Path to kubeconfig file + --kubeconfig-context string Kubeconfig context name (default: current active context) + --no-concierge Generate a configuration which does not use the concierge, but sends the credential to the cluster directly + --oidc-ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated) + --oidc-client-id string OpenID Connect client ID (default: autodiscover) (default "pinniped-cli") + --oidc-issuer string OpenID Connect issuer URL (default: autodiscover) + --oidc-listen-port uint16 TCP port for localhost listener (authorization code flow only) + --oidc-request-audience string Request a token with an alternate audience using RF8693 token exchange + --oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped.sts.unrestricted]) + --oidc-session-cache string Path to OpenID Connect session cache file + --oidc-skip-browser During OpenID Connect login, skip opening the browser (just print the URL) + --static-token string Instead of doing an OIDC-based login, specify a static token + --static-token-env string Instead of doing an OIDC-based login, read a static token from the environment + `), + }, + { + name: "fail to get self-path", + args: []string{}, + getPathToSelfErr: fmt.Errorf("some OS error"), + wantError: true, + wantStdout: here.Doc(` + Error: could not determine the Pinniped executable path: some OS error + `), + }, + { + name: "invalid CA bundle paths", + args: []string{ + "--oidc-ca-bundle", "./does/not/exist", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not read --oidc-ca-bundle: open ./does/not/exist: no such file or directory + `), + }, + { + name: "invalid kubeconfig path", + args: []string{ + "--kubeconfig", "./does/not/exist", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not load --kubeconfig: stat ./does/not/exist: no such file or directory + `), + }, + { + name: "invalid kubeconfig context", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--kubeconfig-context", "invalid", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not load --kubeconfig/--kubeconfig-context: no such context "invalid" + `), + }, + { + name: "clientset creation failure", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + getClientsetErr: fmt.Errorf("some kube error"), + wantError: true, + wantStdout: here.Doc(` + Error: could not configure Kubernetes client: some kube error + `), + }, + { + name: "webhook authenticator not found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + }, + wantError: true, + wantStdout: here.Doc(` + Error: webhookauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found + `), + }, + { + name: "JWT authenticator not found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", "test-authenticator", + }, + wantError: true, + wantStdout: here.Doc(` + Error: jwtauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found + `), + }, + { + name: "invalid authenticator type", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "invalid", + "--concierge-authenticator-name", "test-authenticator", + }, + wantError: true, + wantStdout: here.Doc(` + Error: invalid authenticator type "invalid", supported values are "webhook" and "jwt" + `), + }, + { + name: "fail to autodetect authenticator, listing jwtauthenticators fails", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + conciergeReactions: []kubetesting.Reactor{ + &kubetesting.SimpleReactor{ + Verb: "*", + Resource: "jwtauthenticators", + Reaction: func(kubetesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("some list error") + }, + }, + }, + wantError: true, + wantStdout: here.Doc(` + Error: failed to list JWTAuthenticator objects for autodiscovery: some list error + `), + }, + { + name: "fail to autodetect authenticator, listing webhookauthenticators fails", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + conciergeReactions: []kubetesting.Reactor{ + &kubetesting.SimpleReactor{ + Verb: "*", + Resource: "webhookauthenticators", + Reaction: func(kubetesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("some list error") + }, + }, + }, + wantError: true, + wantStdout: here.Doc(` + Error: failed to list WebhookAuthenticator objects for autodiscovery: some list error + `), + }, + { + name: "fail to autodetect authenticator, none found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + wantError: true, + wantStdout: here.Doc(` + Error: no authenticators were found in namespace "pinniped-concierge" (try setting --concierge-namespace) + `), + }, + { + name: "fail to autodetect authenticator, multiple found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-1", Namespace: "test-namespace"}}, + &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-2", Namespace: "test-namespace"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-3", Namespace: "test-namespace"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-4", Namespace: "test-namespace"}}, + }, + wantError: true, + wantStdout: here.Doc(` + Error: multiple authenticators were found in namespace "test-namespace", so the --concierge-authenticator-type/--concierge-authenticator-name flags must be specified + `), + }, + { + name: "autodetect webhook authenticator, missing --oidc-issuer", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not autodiscover --oidc-issuer, and none was provided + `), + }, + { + name: "autodetect JWT authenticator, invalid TLS bundle", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + TLS: &conciergev1alpha1.TLSSpec{ + CertificateAuthorityData: "invalid-base64", + }, + }, + }, + }, + wantError: true, + wantStdout: here.Doc(` + Error: tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator test-namespace/test-authenticator has invalid spec.tls.certificateAuthorityData: illegal base64 data at input byte 7 + `), + }, + { + name: "invalid static token flags", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + "--static-token", "test-token", + "--static-token-env", "TEST_TOKEN", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantError: true, + wantStdout: here.Doc(` + Error: only one of --static-token and --static-token-env can be specified + `), + }, + { + name: "valid static token", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + "--static-token", "test-token", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantStdout: here.Doc(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - static + - --enable-concierge + - --concierge-namespace=test-namespace + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --token=test-token + command: '.../path/to/pinniped' + env: [] + `), + }, + { + name: "valid static token from env var", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + "--static-token-env", "TEST_TOKEN", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantStdout: here.Doc(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - static + - --enable-concierge + - --concierge-namespace=test-namespace + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --token-env=TEST_TOKEN + command: '.../path/to/pinniped' + env: [] + `), + }, + { + name: "autodetect JWT authenticator", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "pinniped-concierge"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://example.com/issuer", + Audience: "test-audience", + TLS: &conciergev1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(testCA.Bundle()), + }, + }, + }, + }, + wantStdout: here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-namespace=pinniped-concierge + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=https://example.com/issuer + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped.sts.unrestricted + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + `, base64.StdEncoding.EncodeToString(testCA.Bundle())), + }, + { + name: "autodetect nothing, set a bunch of options", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + "--oidc-issuer", "https://example.com/issuer", + "--oidc-skip-browser", + "--oidc-listen-port", "1234", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", "/path/to/cache/dir/sessions.yaml", + "--oidc-debug-session-cache", + "--oidc-request-audience", "test-audience", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "pinniped-concierge"}, + }, + }, + wantStdout: here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-namespace=pinniped-concierge + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=https://example.com/issuer + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped.sts.unrestricted + - --skip-browser + - --listen-port=1234 + - --ca-bundle-data=%s + - --session-cache=/path/to/cache/dir/sessions.yaml + - --debug-session-cache + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + `, base64.StdEncoding.EncodeToString(testCA.Bundle())), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cmd := kubeconfigCommand(kubeconfigDeps{ + getPathToSelf: func() (string, error) { + if tt.getPathToSelfErr != nil { + return "", tt.getPathToSelfErr + } + return ".../path/to/pinniped", nil + }, + getClientset: func(clientConfig clientcmd.ClientConfig) (conciergeclientset.Interface, error) { + if tt.getClientsetErr != nil { + return nil, tt.getClientsetErr + } + fake := fakeconciergeclientset.NewSimpleClientset(tt.conciergeObjects...) + if len(tt.conciergeReactions) > 0 { + fake.ReactionChain = tt.conciergeReactions + } + return fake, nil + }, + }) + require.NotNil(t, cmd) + + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + }) + } +} diff --git a/cmd/pinniped/cmd/login.go b/cmd/pinniped/cmd/login.go index faa8b137..e27442ee 100644 --- a/cmd/pinniped/cmd/login.go +++ b/cmd/pinniped/cmd/login.go @@ -12,7 +12,8 @@ var loginCmd = &cobra.Command{ Use: "login", Short: "login", Long: "Login to a Pinniped server", - SilenceUsage: true, // do not print usage message when commands fail + SilenceUsage: true, // Do not print usage message when commands fail. + Hidden: true, // These commands are not really meant to be used directly by users, so it's confusing to have them discoverable. } //nolint: gochecknoinits diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index ee74238a..e3078458 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -4,21 +4,25 @@ package cmd import ( + "context" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "fmt" "io/ioutil" "net/http" "os" "path/filepath" + "time" "github.com/coreos/go-oidc" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "k8s.io/klog/v2/klogr" + "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -26,114 +30,193 @@ import ( //nolint: gochecknoinits func init() { - loginCmd.AddCommand(oidcLoginCommand(oidcclient.Login)) + loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps())) } -func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error)) *cobra.Command { +type oidcLoginCommandDeps struct { + login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error) + exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error) +} + +func oidcLoginCommandRealDeps() oidcLoginCommandDeps { + return oidcLoginCommandDeps{ + login: oidcclient.Login, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + return client.ExchangeToken(ctx, token) + }, + } +} + +type oidcLoginFlags struct { + issuer string + clientID string + listenPort uint16 + scopes []string + skipBrowser bool + sessionCachePath string + caBundlePaths []string + caBundleData []string + debugSessionCache bool + requestAudience string + conciergeEnabled bool + conciergeNamespace string + conciergeAuthenticatorType string + conciergeAuthenticatorName string + conciergeEndpoint string + conciergeCABundle string +} + +func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { var ( cmd = cobra.Command{ Args: cobra.NoArgs, - Use: "oidc --issuer ISSUER --client-id CLIENT_ID", + Use: "oidc --issuer ISSUER", Short: "Login using an OpenID Connect provider", SilenceUsage: true, } - issuer string - clientID string - listenPort uint16 - scopes []string - skipBrowser bool - sessionCachePath string - caBundlePaths []string - debugSessionCache bool - requestAudience string + flags oidcLoginFlags ) - cmd.Flags().StringVar(&issuer, "issuer", "", "OpenID Connect issuer URL.") - cmd.Flags().StringVar(&clientID, "client-id", "pinniped-cli", "OpenID Connect client ID.") - cmd.Flags().Uint16Var(&listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only).") - cmd.Flags().StringSliceVar(&scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped.sts.unrestricted"}, "OIDC scopes to request during login.") - cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).") - cmd.Flags().StringVar(&sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file.") - cmd.Flags().StringSliceVar(&caBundlePaths, "ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated).") - cmd.Flags().BoolVar(&debugSessionCache, "debug-session-cache", false, "Print debug logs related to the session cache.") - cmd.Flags().StringVar(&requestAudience, "request-audience", "", "Request a token with an alternate audience using RF8693 token exchange.") + cmd.Flags().StringVar(&flags.issuer, "issuer", "", "OpenID Connect issuer URL") + cmd.Flags().StringVar(&flags.clientID, "client-id", "pinniped-cli", "OpenID Connect client ID") + cmd.Flags().Uint16Var(&flags.listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only)") + cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped.sts.unrestricted"}, "OIDC scopes to request during login") + cmd.Flags().BoolVar(&flags.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL)") + cmd.Flags().StringVar(&flags.sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file") + cmd.Flags().StringSliceVar(&flags.caBundlePaths, "ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)") + cmd.Flags().StringSliceVar(&flags.caBundleData, "ca-bundle-data", nil, "Base64 endcoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated)") + cmd.Flags().BoolVar(&flags.debugSessionCache, "debug-session-cache", false, "Print debug logs related to the session cache") + cmd.Flags().StringVar(&flags.requestAudience, "request-audience", "", "Request a token with an alternate audience using RF8693 token exchange") + cmd.Flags().BoolVar(&flags.conciergeEnabled, "enable-concierge", false, "Exchange the OIDC ID token with the Pinniped concierge during login") + cmd.Flags().StringVar(&flags.conciergeNamespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt')") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name") + cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Pinniped concierge endpoint") + cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the concierge") + mustMarkHidden(&cmd, "debug-session-cache") mustMarkRequired(&cmd, "issuer") + cmd.RunE = func(cmd *cobra.Command, args []string) error { return runOIDCLogin(cmd, deps, flags) } + return &cmd +} - cmd.RunE = func(cmd *cobra.Command, args []string) error { - // Initialize the session cache. - var sessionOptions []filesession.Option +func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLoginFlags) error { + // Initialize the session cache. + var sessionOptions []filesession.Option - // If the hidden --debug-session-cache option is passed, log all the errors from the session cache with klog. - if debugSessionCache { - logger := klogr.New().WithName("session") - sessionOptions = append(sessionOptions, filesession.WithErrorReporter(func(err error) { - logger.Error(err, "error during session cache operation") - })) + // If the hidden --debug-session-cache option is passed, log all the errors from the session cache with klog. + if flags.debugSessionCache { + logger := klogr.New().WithName("session") + sessionOptions = append(sessionOptions, filesession.WithErrorReporter(func(err error) { + logger.Error(err, "error during session cache operation") + })) + } + sessionCache := filesession.New(flags.sessionCachePath, sessionOptions...) + + // Initialize the login handler. + opts := []oidcclient.Option{ + oidcclient.WithContext(cmd.Context()), + oidcclient.WithScopes(flags.scopes), + oidcclient.WithSessionCache(sessionCache), + } + + if flags.listenPort != 0 { + opts = append(opts, oidcclient.WithListenPort(flags.listenPort)) + } + + if flags.requestAudience != "" { + opts = append(opts, oidcclient.WithRequestAudience(flags.requestAudience)) + } + + var concierge *conciergeclient.Client + if flags.conciergeEnabled { + var err error + concierge, err = conciergeclient.New( + conciergeclient.WithNamespace(flags.conciergeNamespace), + conciergeclient.WithEndpoint(flags.conciergeEndpoint), + conciergeclient.WithBase64CABundle(flags.conciergeCABundle), + conciergeclient.WithAuthenticator(flags.conciergeAuthenticatorType, flags.conciergeAuthenticatorName), + ) + if err != nil { + return fmt.Errorf("invalid concierge parameters: %w", err) } - sessionCache := filesession.New(sessionCachePath, sessionOptions...) + } - // Initialize the login handler. - opts := []oidcclient.Option{ - oidcclient.WithContext(cmd.Context()), - oidcclient.WithScopes(scopes), - oidcclient.WithSessionCache(sessionCache), - } + // --skip-browser replaces the default "browser open" function with one that prints to stderr. + if flags.skipBrowser { + opts = append(opts, oidcclient.WithBrowserOpen(func(url string) error { + cmd.PrintErr("Please log in: ", url, "\n") + return nil + })) + } - if listenPort != 0 { - opts = append(opts, oidcclient.WithListenPort(listenPort)) - } - - if requestAudience != "" { - opts = append(opts, oidcclient.WithRequestAudience(requestAudience)) - } - - // --skip-browser replaces the default "browser open" function with one that prints to stderr. - if skipBrowser { - opts = append(opts, oidcclient.WithBrowserOpen(func(url string) error { - cmd.PrintErr("Please log in: ", url, "\n") - return nil - })) - } - - if len(caBundlePaths) > 0 { - pool := x509.NewCertPool() - for _, p := range caBundlePaths { - pem, err := ioutil.ReadFile(p) - if err != nil { - return fmt.Errorf("could not read --ca-bundle: %w", err) - } - pool.AppendCertsFromPEM(pem) - } - tlsConfig := tls.Config{ - RootCAs: pool, - MinVersion: tls.VersionTLS12, - } - opts = append(opts, oidcclient.WithClient(&http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tlsConfig, - }, - })) - } - - tok, err := loginFunc(issuer, clientID, opts...) + if len(flags.caBundlePaths) > 0 || len(flags.caBundleData) > 0 { + client, err := makeClient(flags.caBundlePaths, flags.caBundleData) if err != nil { return err } - - // Convert the token out to Kubernetes ExecCredential JSON format for output. - return json.NewEncoder(cmd.OutOrStdout()).Encode(&clientauthenticationv1beta1.ExecCredential{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExecCredential", - APIVersion: "client.authentication.k8s.io/v1beta1", - }, - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - ExpirationTimestamp: &tok.IDToken.Expiry, - Token: tok.IDToken.Token, - }, - }) + opts = append(opts, oidcclient.WithClient(client)) } - return &cmd + + // Do the basic login to get an OIDC token. + token, err := deps.login(flags.issuer, flags.clientID, opts...) + if err != nil { + return fmt.Errorf("could not complete Pinniped login: %w", err) + } + cred := tokenCredential(token) + + // If the concierge was configured, exchange the credential for a separate short-lived, cluster-specific credential. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if concierge != nil { + cred, err = deps.exchangeToken(ctx, concierge, token.IDToken.Token) + if err != nil { + return fmt.Errorf("could not complete concierge credential exchange: %w", err) + } + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) +} +func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, error) { + pool := x509.NewCertPool() + for _, p := range caBundlePaths { + pem, err := ioutil.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("could not read --ca-bundle: %w", err) + } + pool.AppendCertsFromPEM(pem) + } + for _, d := range caBundleData { + pem, err := base64.StdEncoding.DecodeString(d) + if err != nil { + return nil, fmt.Errorf("could not read --ca-bundle-data: %w", err) + } + pool.AppendCertsFromPEM(pem) + } + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS12, + }, + }, + }, nil +} + +func tokenCredential(token *oidctypes.Token) *clientauthv1beta1.ExecCredential { + cred := clientauthv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthv1beta1.ExecCredentialStatus{ + Token: token.IDToken.Token, + }, + } + if !token.IDToken.Expiry.IsZero() { + cred.Status.ExpirationTimestamp = &token.IDToken.Expiry + } + return &cred } // mustGetConfigDir returns a directory that follows the XDG base directory convention: diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index f42a2224..df4f8f9d 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -5,13 +5,23 @@ package cmd import ( "bytes" + "context" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "io/ioutil" + "path/filepath" "testing" "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/oidctypes" ) @@ -19,16 +29,22 @@ import ( func TestLoginOIDCCommand(t *testing.T) { cfgDir := mustGetConfigDir() + testCA, err := certauthority.New(pkix.Name{CommonName: "Test CA"}, 1*time.Hour) + require.NoError(t, err) + tmpdir := testutil.TempDir(t) + testCABundlePath := filepath.Join(tmpdir, "testca.pem") + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCA.Bundle(), 0600)) + time1 := time.Date(3020, 10, 12, 13, 14, 15, 16, time.UTC) tests := []struct { name string args []string + loginErr error + conciergeErr error wantError bool wantStdout string wantStderr string - wantIssuer string - wantClientID string wantOptionsCount int }{ { @@ -38,18 +54,25 @@ func TestLoginOIDCCommand(t *testing.T) { Login using an OpenID Connect provider Usage: - oidc --issuer ISSUER --client-id CLIENT_ID [flags] + oidc --issuer ISSUER [flags] Flags: - --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated). - --client-id string OpenID Connect client ID. (default "pinniped-cli") - -h, --help help for oidc - --issuer string OpenID Connect issuer URL. - --listen-port uint16 TCP port for localhost listener (authorization code flow only). - --request-audience string Request a token with an alternate audience using RF8693 token exchange. - --scopes strings OIDC scopes to request during login. (default [offline_access,openid,pinniped.sts.unrestricted]) - --session-cache string Path to session cache file. (default "` + cfgDir + `/sessions.yaml") - --skip-browser Skip opening the browser (just print the URL). + --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated) + --ca-bundle-data strings Base64 endcoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated) + --client-id string OpenID Connect client ID (default "pinniped-cli") + --concierge-authenticator-name string Concierge authenticator name + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') + --concierge-ca-bundle-data string CA bundle to use when connecting to the concierge + --concierge-endpoint string API base for the Pinniped concierge endpoint + --concierge-namespace string Namespace in which the concierge was installed (default "pinniped-concierge") + --enable-concierge Exchange the OIDC ID token with the Pinniped concierge during login + -h, --help help for oidc + --issuer string OpenID Connect issuer URL + --listen-port uint16 TCP port for localhost listener (authorization code flow only) + --request-audience string Request a token with an alternate audience using RF8693 token exchange + --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped.sts.unrestricted]) + --session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml") + --skip-browser Skip opening the browser (just print the URL) `), }, { @@ -60,14 +83,78 @@ func TestLoginOIDCCommand(t *testing.T) { Error: required flag(s) "issuer" not set `), }, + { + name: "missing concierge flags", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--enable-concierge", + }, + wantError: true, + wantStdout: here.Doc(` + Error: invalid concierge parameters: endpoint must not be empty + `), + }, + { + name: "invalid CA bundle path", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--ca-bundle", "./does/not/exist", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not read --ca-bundle: open ./does/not/exist: no such file or directory + `), + }, + { + name: "invalid CA bundle data", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--ca-bundle-data", "invalid-base64", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not read --ca-bundle-data: illegal base64 data at input byte 7 + `), + }, + { + name: "login error", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + }, + loginErr: fmt.Errorf("some login error"), + wantOptionsCount: 3, + wantError: true, + wantStdout: here.Doc(` + Error: could not complete Pinniped login: some login error + `), + }, + { + name: "concierge token exchange error", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--enable-concierge", + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", "test-authenticator", + "--concierge-endpoint", "https://127.0.0.1:1234/", + }, + conciergeErr: fmt.Errorf("some concierge error"), + wantOptionsCount: 3, + wantError: true, + wantStdout: here.Doc(` + Error: could not complete concierge credential exchange: some concierge error + `), + }, { name: "success with minimal options", args: []string{ "--client-id", "test-client-id", "--issuer", "test-issuer", }, - wantIssuer: "test-issuer", - wantClientID: "test-client-id", wantOptionsCount: 3, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, @@ -79,31 +166,56 @@ func TestLoginOIDCCommand(t *testing.T) { "--skip-browser", "--listen-port", "1234", "--debug-session-cache", + "--request-audience", "cluster-1234", + "--ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()), + "--ca-bundle", testCABundlePath, + "--enable-concierge", + "--concierge-namespace", "test-namespace", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + "--concierge-endpoint", "https://127.0.0.1:1234/", + "--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()), }, - wantIssuer: "test-issuer", - wantClientID: "test-client-id", - wantOptionsCount: 5, - wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", + wantOptionsCount: 7, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { var ( - gotIssuer string - gotClientID string - gotOptions []oidcclient.Option + gotOptions []oidcclient.Option ) - cmd := oidcLoginCommand(func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error) { - gotIssuer = issuer - gotClientID = clientID - gotOptions = opts - return &oidctypes.Token{ - IDToken: &oidctypes.IDToken{ - Token: "test-id-token", - Expiry: metav1.NewTime(time1), - }, - }, nil + cmd := oidcLoginCommand(oidcLoginCommandDeps{ + login: func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error) { + require.Equal(t, "test-issuer", issuer) + require.Equal(t, "test-client-id", clientID) + gotOptions = opts + if tt.loginErr != nil { + return nil, tt.loginErr + } + return &oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Token: "test-id-token", + Expiry: metav1.NewTime(time1), + }, + }, nil + }, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + require.Equal(t, token, "test-id-token") + if tt.conciergeErr != nil { + return nil, tt.conciergeErr + } + return &clientauthv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthv1beta1.ExecCredentialStatus{ + Token: "exchanged-token", + }, + }, nil + }, }) require.NotNil(t, cmd) @@ -119,8 +231,6 @@ func TestLoginOIDCCommand(t *testing.T) { } require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") - require.Equal(t, tt.wantIssuer, gotIssuer, "unexpected issuer") - require.Equal(t, tt.wantClientID, gotClientID, "unexpected client ID") require.Len(t, gotOptions, tt.wantOptionsCount) }) } diff --git a/cmd/pinniped/cmd/login_static.go b/cmd/pinniped/cmd/login_static.go new file mode 100644 index 00000000..a051e4fa --- /dev/null +++ b/cmd/pinniped/cmd/login_static.go @@ -0,0 +1,120 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + "go.pinniped.dev/pkg/conciergeclient" + "go.pinniped.dev/pkg/oidcclient/oidctypes" +) + +//nolint: gochecknoinits +func init() { + loginCmd.AddCommand(staticLoginCommand(staticLoginRealDeps())) +} + +type staticLoginDeps struct { + lookupEnv func(string) (string, bool) + exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error) +} + +func staticLoginRealDeps() staticLoginDeps { + return staticLoginDeps{ + lookupEnv: os.LookupEnv, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + return client.ExchangeToken(ctx, token) + }, + } +} + +type staticLoginParams struct { + staticToken string + staticTokenEnvName string + conciergeEnabled bool + conciergeNamespace string + conciergeAuthenticatorType string + conciergeAuthenticatorName string + conciergeEndpoint string + conciergeCABundle string +} + +func staticLoginCommand(deps staticLoginDeps) *cobra.Command { + var ( + cmd = cobra.Command{ + Args: cobra.NoArgs, + Use: "static [--token TOKEN] [--token-env TOKEN_NAME]", + Short: "Login using a static token", + SilenceUsage: true, + } + flags staticLoginParams + ) + cmd.Flags().StringVar(&flags.staticToken, "token", "", "Static token to present during login") + cmd.Flags().StringVar(&flags.staticTokenEnvName, "token-env", "", "Environment variable containing a static token") + cmd.Flags().BoolVar(&flags.conciergeEnabled, "enable-concierge", false, "Exchange the token with the Pinniped concierge during login") + cmd.Flags().StringVar(&flags.conciergeNamespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt')") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name") + cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Pinniped concierge endpoint") + cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the concierge") + cmd.RunE = func(cmd *cobra.Command, args []string) error { return runStaticLogin(cmd.OutOrStdout(), deps, flags) } + return &cmd +} + +func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams) error { + if flags.staticToken == "" && flags.staticTokenEnvName == "" { + return fmt.Errorf("one of --token or --token-env must be set") + } + + var concierge *conciergeclient.Client + if flags.conciergeEnabled { + var err error + concierge, err = conciergeclient.New( + conciergeclient.WithNamespace(flags.conciergeNamespace), + conciergeclient.WithEndpoint(flags.conciergeEndpoint), + conciergeclient.WithBase64CABundle(flags.conciergeCABundle), + conciergeclient.WithAuthenticator(flags.conciergeAuthenticatorType, flags.conciergeAuthenticatorName), + ) + if err != nil { + return fmt.Errorf("invalid concierge parameters: %w", err) + } + } + + var token string + if flags.staticToken != "" { + token = flags.staticToken + } + if flags.staticTokenEnvName != "" { + var ok bool + token, ok = deps.lookupEnv(flags.staticTokenEnvName) + if !ok { + return fmt.Errorf("--token-env variable %q is not set", flags.staticTokenEnvName) + } + if token == "" { + return fmt.Errorf("--token-env variable %q is empty", flags.staticTokenEnvName) + } + } + cred := tokenCredential(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: token}}) + + // Exchange that token with the concierge, if configured. + if concierge != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var err error + cred, err = deps.exchangeToken(ctx, concierge, token) + if err != nil { + return fmt.Errorf("could not complete concierge credential exchange: %w", err) + } + } + return json.NewEncoder(out).Encode(cred) +} diff --git a/cmd/pinniped/cmd/login_static_test.go b/cmd/pinniped/cmd/login_static_test.go new file mode 100644 index 00000000..2b98a76b --- /dev/null +++ b/cmd/pinniped/cmd/login_static_test.go @@ -0,0 +1,180 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "crypto/x509/pkix" + "fmt" + "io/ioutil" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/pkg/conciergeclient" +) + +func TestLoginStaticCommand(t *testing.T) { + testCA, err := certauthority.New(pkix.Name{CommonName: "Test CA"}, 1*time.Hour) + require.NoError(t, err) + tmpdir := testutil.TempDir(t) + testCABundlePath := filepath.Join(tmpdir, "testca.pem") + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCA.Bundle(), 0600)) + + tests := []struct { + name string + args []string + env map[string]string + loginErr error + conciergeErr error + wantError bool + wantStdout string + wantStderr string + wantOptionsCount int + }{ + { + name: "help flag passed", + args: []string{"--help"}, + wantStdout: here.Doc(` + Login using a static token + + Usage: + static [--token TOKEN] [--token-env TOKEN_NAME] [flags] + + Flags: + --concierge-authenticator-name string Concierge authenticator name + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') + --concierge-ca-bundle-data string CA bundle to use when connecting to the concierge + --concierge-endpoint string API base for the Pinniped concierge endpoint + --concierge-namespace string Namespace in which the concierge was installed (default "pinniped-concierge") + --enable-concierge Exchange the token with the Pinniped concierge during login + -h, --help help for static + --token string Static token to present during login + --token-env string Environment variable containing a static token + `), + }, + { + name: "missing required flags", + args: []string{}, + wantError: true, + wantStdout: here.Doc(` + Error: one of --token or --token-env must be set + `), + }, + { + name: "missing concierge flags", + args: []string{ + "--token", "test-token", + "--enable-concierge", + }, + wantError: true, + wantStdout: here.Doc(` + Error: invalid concierge parameters: endpoint must not be empty + `), + }, + { + name: "missing env var", + args: []string{ + "--token-env", "TEST_TOKEN_ENV", + }, + wantError: true, + wantStdout: here.Doc(` + Error: --token-env variable "TEST_TOKEN_ENV" is not set + `), + }, + { + name: "empty env var", + args: []string{ + "--token-env", "TEST_TOKEN_ENV", + }, + env: map[string]string{ + "TEST_TOKEN_ENV": "", + }, + wantError: true, + wantStdout: here.Doc(` + Error: --token-env variable "TEST_TOKEN_ENV" is empty + `), + }, + { + name: "env var token success", + args: []string{ + "--token-env", "TEST_TOKEN_ENV", + }, + env: map[string]string{ + "TEST_TOKEN_ENV": "test-token", + }, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"test-token"}}` + "\n", + }, + { + name: "concierge failure", + args: []string{ + "--token", "test-token", + "--enable-concierge", + "--concierge-endpoint", "https://127.0.0.1/", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + }, + conciergeErr: fmt.Errorf("some concierge error"), + wantError: true, + wantStdout: here.Doc(` + Error: could not complete concierge credential exchange: some concierge error + `), + }, + { + name: "static token success", + args: []string{ + "--token", "test-token", + }, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"test-token"}}` + "\n", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cmd := staticLoginCommand(staticLoginDeps{ + lookupEnv: func(s string) (string, bool) { + v, ok := tt.env[s] + return v, ok + }, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + require.Equal(t, token, "test-token") + if tt.conciergeErr != nil { + return nil, tt.conciergeErr + } + return &clientauthv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthv1beta1.ExecCredentialStatus{ + Token: "exchanged-token", + }, + }, nil + }, + }) + require.NotNil(t, cmd) + + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + }) + } +} From f9691208d5c2f217779765c757db762d74e214d8 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Mon, 14 Dec 2020 18:41:11 -0600 Subject: [PATCH 05/16] Add library.NewRestConfigFromKubeconfig() test helper. This is extracted from library.NewClientsetForKubeConfig(). It is useful so you can assert properties of the loaded, parsed kubeconfig. Signed-off-by: Matt Moyer --- test/library/client.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/library/client.go b/test/library/client.go index 3593b92e..0c28d6c4 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -50,7 +50,10 @@ func NewClientset(t *testing.T) kubernetes.Interface { func NewClientsetForKubeConfig(t *testing.T, kubeConfig string) kubernetes.Interface { t.Helper() + return newClientsetWithConfig(t, NewRestConfigFromKubeconfig(t, kubeConfig)) +} +func NewRestConfigFromKubeconfig(t *testing.T, kubeConfig string) *rest.Config { kubeConfigFile, err := ioutil.TempFile("", "pinniped-cli-test-*") require.NoError(t, err) defer os.Remove(kubeConfigFile.Name()) @@ -60,8 +63,7 @@ func NewClientsetForKubeConfig(t *testing.T, kubeConfig string) kubernetes.Inter restConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigFile.Name()) require.NoError(t, err) - - return newClientsetWithConfig(t, restConfig) + return restConfig } func NewClientsetWithCertAndKey(t *testing.T, clientCertificateData, clientKeyData string) kubernetes.Interface { From fe4e2d620d1ab5f6d8a7e0f52a9a92acb3c7ef41 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Mon, 14 Dec 2020 13:25:03 -0600 Subject: [PATCH 06/16] Update TestCLIGetKubeconfig to ignore stderr output from `get-kubeconfig`. This will now have a deprecation warning, so we can't treat is as part of the YAML output. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 707fe0e7..c0858a38 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -102,9 +102,14 @@ func runPinnipedCLIGetKubeconfig(t *testing.T, pinnipedExe, token, namespaceName "--pinniped-namespace", namespaceName, "--authenticator-type", authenticatorType, "--authenticator-name", authenticatorName, - ).CombinedOutput() - require.NoError(t, err, string(output)) + ).Output() + // Log stderr if there is a problem. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + t.Logf("stderr:\n%s\n", string(exitErr.Stderr)) + } + require.NoError(t, err, string(output)) return string(output) } From b6edc3dc0886bb780504df8bb971987569f7cfd6 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Mon, 14 Dec 2020 18:42:02 -0600 Subject: [PATCH 07/16] Replace TestCLIGetKubeconfig with TestCLIGetKubeconfigStaticToken. It now tests both the deprecated `pinniped get-kubeconfig` and the new `pinniped get kubeconfig --static-token` flows. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 109 +++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 42 deletions(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index c0858a38..aaf3e2c4 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -32,7 +32,7 @@ import ( "go.pinniped.dev/test/library/browsertest" ) -func TestCLIGetKubeconfig(t *testing.T) { +func TestCLIGetKubeconfigStaticToken(t *testing.T) { env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) // Create a test webhook configuration to use with the CLI. @@ -44,32 +44,68 @@ func TestCLIGetKubeconfig(t *testing.T) { // Build pinniped CLI. pinnipedExe := buildPinnipedCLI(t) - // Run pinniped CLI to get kubeconfig. - kubeConfigYAML := runPinnipedCLIGetKubeconfig(t, pinnipedExe, env.TestUser.Token, env.ConciergeNamespace, "webhook", authenticator.Name) + for _, tt := range []struct { + name string + args []string + expectStderr string + }{ + { + name: "deprecated command", + args: []string{ + "get-kubeconfig", + "--token", env.TestUser.Token, + "--pinniped-namespace", env.ConciergeNamespace, + "--authenticator-type", "webhook", + "--authenticator-name", authenticator.Name, + }, + expectStderr: "Command \"get-kubeconfig\" is deprecated, Please use `pinniped get kubeconfig` instead.\n", + }, + { + name: "newer command, but still using static parameters", + args: []string{ + "get", "kubeconfig", + "--static-token", env.TestUser.Token, + "--concierge-namespace", env.ConciergeNamespace, + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", authenticator.Name, + }, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + stdout, stderr := runPinnipedCLI(t, pinnipedExe, tt.args...) + require.Equal(t, tt.expectStderr, stderr) - // In addition to the client-go based testing below, also try the kubeconfig - // with kubectl to validate that it works. - adminClient := library.NewClientset(t) - t.Run( - "access as user with kubectl", - library.AccessAsUserWithKubectlTest(ctx, adminClient, kubeConfigYAML, env.TestUser.ExpectedUsername, env.ConciergeNamespace), - ) - for _, group := range env.TestUser.ExpectedGroups { - group := group - t.Run( - "access as group "+group+" with kubectl", - library.AccessAsGroupWithKubectlTest(ctx, adminClient, kubeConfigYAML, group, env.ConciergeNamespace), - ) - } + // Even the deprecated command should now generate a kubeconfig with the new "pinniped login static" command. + restConfig := library.NewRestConfigFromKubeconfig(t, stdout) + require.NotNil(t, restConfig.ExecProvider) + require.Equal(t, []string{"login", "static"}, restConfig.ExecProvider.Args[:2]) - // Create Kubernetes client with kubeconfig from pinniped CLI. - kubeClient := library.NewClientsetForKubeConfig(t, kubeConfigYAML) + // In addition to the client-go based testing below, also try the kubeconfig + // with kubectl to validate that it works. + adminClient := library.NewClientset(t) + t.Run( + "access as user with kubectl", + library.AccessAsUserWithKubectlTest(ctx, adminClient, stdout, env.TestUser.ExpectedUsername, env.ConciergeNamespace), + ) + for _, group := range env.TestUser.ExpectedGroups { + group := group + t.Run( + "access as group "+group+" with kubectl", + library.AccessAsGroupWithKubectlTest(ctx, adminClient, stdout, group, env.ConciergeNamespace), + ) + } - // Validate that we can auth to the API via our user. - t.Run("access as user with client-go", library.AccessAsUserTest(ctx, adminClient, env.TestUser.ExpectedUsername, kubeClient)) - for _, group := range env.TestUser.ExpectedGroups { - group := group - t.Run("access as group "+group+" with client-go", library.AccessAsGroupTest(ctx, adminClient, group, kubeClient)) + // Create Kubernetes client with kubeconfig from pinniped CLI. + kubeClient := library.NewClientsetForKubeConfig(t, stdout) + + // Validate that we can auth to the API via our user. + t.Run("access as user with client-go", library.AccessAsUserTest(ctx, adminClient, env.TestUser.ExpectedUsername, kubeClient)) + for _, group := range env.TestUser.ExpectedGroups { + group := group + t.Run("access as group "+group+" with client-go", library.AccessAsGroupTest(ctx, adminClient, group, kubeClient)) + } + }) } } @@ -92,25 +128,14 @@ func buildPinnipedCLI(t *testing.T) string { return pinnipedExe } -func runPinnipedCLIGetKubeconfig(t *testing.T, pinnipedExe, token, namespaceName, authenticatorType, authenticatorName string) string { +func runPinnipedCLI(t *testing.T, pinnipedExe string, args ...string) (string, string) { t.Helper() - - output, err := exec.Command( - pinnipedExe, - "get-kubeconfig", - "--token", token, - "--pinniped-namespace", namespaceName, - "--authenticator-type", authenticatorType, - "--authenticator-name", authenticatorName, - ).Output() - - // Log stderr if there is a problem. - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - t.Logf("stderr:\n%s\n", string(exitErr.Stderr)) - } - require.NoError(t, err, string(output)) - return string(output) + var stdout, stderr bytes.Buffer + cmd := exec.Command(pinnipedExe, args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoErrorf(t, cmd.Run(), "stderr:\n%s\n\nstdout:\n%s\n\n", stderr.String(), stdout.String()) + return stdout.String(), stderr.String() } func TestCLILoginOIDC(t *testing.T) { From 4088793cc5cd9e3bf109181013159ed72f553abf Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 11:45:40 -0600 Subject: [PATCH 08/16] Add a .ProxyEnv() helper on the test environment. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 8 +------- test/library/env.go | 8 ++++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index aaf3e2c4..33a0cda1 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -372,12 +372,6 @@ func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, ses } // If there is a custom proxy, set it using standard environment variables. - if env.Proxy != "" { - cmd.Env = append(os.Environ(), - "http_proxy="+env.Proxy, - "https_proxy="+env.Proxy, - "no_proxy=127.0.0.1", - ) - } + cmd.Env = append(os.Environ(), env.ProxyEnv()...) return cmd } diff --git a/test/library/env.go b/test/library/env.go index e6f3c1da..41ab3329 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -59,6 +59,14 @@ type TestOIDCUpstream struct { Password string `json:"password"` } +// ProxyEnv returns a set of environment variable strings (e.g., to combine with os.Environ()) which set up the configured test HTTP proxy. +func (e *TestEnv) ProxyEnv() []string { + if e.Proxy == "" { + return nil + } + return []string{"http_proxy=" + e.Proxy, "https_proxy=" + e.Proxy, "no_proxy=127.0.0.1"} +} + // IntegrationEnv gets the integration test environment from OS environment variables. This // method also implies SkipUnlessIntegration(). func IntegrationEnv(t *testing.T) *TestEnv { From ad5e257600ab5f44f999f96e0eea77f1ab2471c1 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 11:47:04 -0600 Subject: [PATCH 09/16] Add a library.RandHex() test helper. Signed-off-by: Matt Moyer --- test/library/client.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/library/client.go b/test/library/client.go index 0c28d6c4..777980eb 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -234,7 +234,7 @@ func CreateTestOIDCProvider(ctx context.Context, t *testing.T, issuer string, ce defer cancel() if issuer == "" { - issuer = randomIssuer(t) + issuer = fmt.Sprintf("http://test-issuer-%s.pinniped.dev", RandHex(t, 8)) } opcs := NewSupervisorClientset(t).ConfigV1alpha1().OIDCProviders(testEnv.SupervisorNamespace) @@ -279,11 +279,11 @@ func CreateTestOIDCProvider(ctx context.Context, t *testing.T, issuer string, ce return opc } -func randomIssuer(t *testing.T) string { - var buf [8]byte - _, err := io.ReadFull(rand.Reader, buf[:]) +func RandHex(t *testing.T, numBytes int) string { + buf := make([]byte, numBytes) + _, err := io.ReadFull(rand.Reader, buf) require.NoError(t, err) - return fmt.Sprintf("http://test-issuer-%s.pinniped.dev", hex.EncodeToString(buf[:])) + return hex.EncodeToString(buf) } func CreateTestSecret(t *testing.T, namespace string, baseName string, secretType string, stringData map[string]string) *corev1.Secret { From 70fd330178310ecf42c06e803aa2db4e77c2f391 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 11:49:45 -0600 Subject: [PATCH 10/16] Add library.CreateTestClusterRoleBinding test helper. Signed-off-by: Matt Moyer --- test/library/client.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/library/client.go b/test/library/client.go index 777980eb..2a46af2d 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -355,6 +356,31 @@ func CreateTestUpstreamOIDCProvider(t *testing.T, spec idpv1alpha1.UpstreamOIDCP return result } +func CreateTestClusterRoleBinding(t *testing.T, subject rbacv1.Subject, roleRef rbacv1.RoleRef) *rbacv1.ClusterRoleBinding { + t.Helper() + client := NewClientset(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Create the ClusterRoleBinding using GenerateName to get a random name. + clusterRoles := client.RbacV1().ClusterRoleBindings() + + created, err := clusterRoles.Create(ctx, &rbacv1.ClusterRoleBinding{ + ObjectMeta: testObjectMeta(t, "cluster-role"), + Subjects: []rbacv1.Subject{subject}, + RoleRef: roleRef, + }, metav1.CreateOptions{}) + require.NoError(t, err) + t.Logf("created test ClusterRoleBinding %s", created.Name) + + t.Cleanup(func() { + t.Logf("cleaning up test ClusterRoleBinding %s", created.Name) + err := clusterRoles.Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + return created +} + func testObjectMeta(t *testing.T, baseName string) metav1.ObjectMeta { return metav1.ObjectMeta{ GenerateName: fmt.Sprintf("test-%s-", baseName), From 8cdcb89cefcf569a7aedf5943ca6a1010e0373c5 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 12:19:42 -0600 Subject: [PATCH 11/16] Add a library.PinnipedCLIPath() test helper, with caching. Caching saves us a little bit of time now that we're using the CLI in more and more tests. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 24 +--------- .../concierge_credentialrequest_test.go | 2 +- test/library/cli.go | 45 +++++++++++++++++++ 3 files changed, 48 insertions(+), 23 deletions(-) create mode 100644 test/library/cli.go diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 33a0cda1..1259ccce 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -42,7 +42,7 @@ func TestCLIGetKubeconfigStaticToken(t *testing.T) { authenticator := library.CreateTestWebhookAuthenticator(ctx, t) // Build pinniped CLI. - pinnipedExe := buildPinnipedCLI(t) + pinnipedExe := library.PinnipedCLIPath(t) for _, tt := range []struct { name string @@ -109,25 +109,6 @@ func TestCLIGetKubeconfigStaticToken(t *testing.T) { } } -func buildPinnipedCLI(t *testing.T) string { - t.Helper() - - pinnipedExeDir, err := ioutil.TempDir("", "pinniped-cli-test-*") - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, os.RemoveAll(pinnipedExeDir)) }) - - pinnipedExe := filepath.Join(pinnipedExeDir, "pinniped") - output, err := exec.Command( - "go", - "build", - "-o", - pinnipedExe, - "go.pinniped.dev/cmd/pinniped", - ).CombinedOutput() - require.NoError(t, err, string(output)) - return pinnipedExe -} - func runPinnipedCLI(t *testing.T, pinnipedExe string, args ...string) (string, string) { t.Helper() var stdout, stderr bytes.Buffer @@ -145,8 +126,7 @@ func TestCLILoginOIDC(t *testing.T) { defer cancel() // Build pinniped CLI. - t.Logf("building CLI binary") - pinnipedExe := buildPinnipedCLI(t) + pinnipedExe := library.PinnipedCLIPath(t) // Run "pinniped login oidc" to get an ExecCredential struct with an OIDC ID token. credOutput, sessionCachePath := runPinniedLoginOIDC(ctx, t, pinnipedExe) diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 5e2458e0..1a53ef38 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -65,7 +65,7 @@ func TestSuccessfulCredentialRequest(t *testing.T) { return library.CreateTestJWTAuthenticator(ctx, t) }, token: func(t *testing.T) (string, string, []string) { - pinnipedExe := buildPinnipedCLI(t) + pinnipedExe := library.PinnipedCLIPath(t) credOutput, _ := runPinniedLoginOIDC(ctx, t, pinnipedExe) token := credOutput.Status.Token diff --git a/test/library/cli.go b/test/library/cli.go new file mode 100644 index 00000000..d68e2439 --- /dev/null +++ b/test/library/cli.go @@ -0,0 +1,45 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package library + +import ( + "io/ioutil" + "os/exec" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/testutil" +) + +//nolint: gochecknoglobals +var pinnipedCLIBinaryCache struct { + buf []byte + mutex sync.Mutex +} + +// PinnipedCLIPath returns the path to the Pinniped CLI binary, built on demand and cached between tests. +func PinnipedCLIPath(t *testing.T) string { + t.Helper() + pinnipedCLIBinaryCache.mutex.Lock() + defer pinnipedCLIBinaryCache.mutex.Unlock() + path := filepath.Join(testutil.TempDir(t), "pinniped") + if pinnipedCLIBinaryCache.buf != nil { + t.Log("using previously built pinniped CLI binary") + require.NoError(t, ioutil.WriteFile(path, pinnipedCLIBinaryCache.buf, 0500)) + return path + } + + t.Log("building pinniped CLI binary") + output, err := exec.Command("go", "build", "-o", path, "go.pinniped.dev/cmd/pinniped").CombinedOutput() + require.NoError(t, err, string(output)) + + // Fill our cache so we don't have to do this again. + pinnipedCLIBinaryCache.buf, err = ioutil.ReadFile(path) + require.NoError(t, err, string(output)) + + return path +} From aca9af748b7e55be7e64154225bcd6917f7f151d Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 12:23:52 -0600 Subject: [PATCH 12/16] Cleanup TestSuccessfulCredentialRequest and TestCLILoginOIDC a little. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 4 ++-- .../concierge_credentialrequest_test.go | 18 +++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 1259ccce..8de33728 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -129,7 +129,7 @@ func TestCLILoginOIDC(t *testing.T) { pinnipedExe := library.PinnipedCLIPath(t) // Run "pinniped login oidc" to get an ExecCredential struct with an OIDC ID token. - credOutput, sessionCachePath := runPinniedLoginOIDC(ctx, t, pinnipedExe) + credOutput, sessionCachePath := runPinnipedLoginOIDC(ctx, t, pinnipedExe) // Assert some properties of the ExecCredential. t.Logf("validating ExecCredential") @@ -195,7 +195,7 @@ func TestCLILoginOIDC(t *testing.T) { require.NotEqual(t, credOutput2.Status.Token, credOutput3.Status.Token) } -func runPinniedLoginOIDC( +func runPinnipedLoginOIDC( ctx context.Context, t *testing.T, pinnipedExe string, diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 1a53ef38..8e3e6a99 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -47,26 +47,22 @@ func TestSuccessfulCredentialRequest(t *testing.T) { tests := []struct { name string - authenticator func(t *testing.T) corev1.TypedLocalObjectReference + authenticator func(context.Context, *testing.T) corev1.TypedLocalObjectReference token func(t *testing.T) (token string, username string, groups []string) }{ { - name: "webhook", - authenticator: func(t *testing.T) corev1.TypedLocalObjectReference { - return library.CreateTestWebhookAuthenticator(ctx, t) - }, + name: "webhook", + authenticator: library.CreateTestWebhookAuthenticator, token: func(t *testing.T) (string, string, []string) { return library.IntegrationEnv(t).TestUser.Token, env.TestUser.ExpectedUsername, env.TestUser.ExpectedGroups }, }, { - name: "jwt authenticator", - authenticator: func(t *testing.T) corev1.TypedLocalObjectReference { - return library.CreateTestJWTAuthenticator(ctx, t) - }, + name: "jwt authenticator", + authenticator: library.CreateTestJWTAuthenticator, token: func(t *testing.T) (string, string, []string) { pinnipedExe := library.PinnipedCLIPath(t) - credOutput, _ := runPinniedLoginOIDC(ctx, t, pinnipedExe) + credOutput, _ := runPinnipedLoginOIDC(ctx, t, pinnipedExe) token := credOutput.Status.Token // By default, the JWTAuthenticator expects the username to be in the "sub" claim and the @@ -80,7 +76,7 @@ func TestSuccessfulCredentialRequest(t *testing.T) { for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { - authenticator := test.authenticator(t) + authenticator := test.authenticator(ctx, t) token, username, groups := test.token(t) var response *loginv1alpha1.TokenCredentialRequest From 5ad3c65ae1d7789e24bd2d74a348aa2b6b2aa6ab Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 12:24:28 -0600 Subject: [PATCH 13/16] Close the right pipe output in runPinnipedLoginOIDC. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 8de33728..a859dcac 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -254,7 +254,7 @@ func runPinnipedLoginOIDC( credOutputChan := make(chan clientauthenticationv1beta1.ExecCredential) spawnTestGoroutine(t, func() (err error) { defer func() { - closeErr := stderr.Close() + closeErr := stdout.Close() if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { return } From e0eba9d5a68a4e29d8ccbb7d54f5351c0f6c79da Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 12:25:31 -0600 Subject: [PATCH 14/16] Refactor library.CreateTestJWTAuthenticator() so we can also use the supervisor as an upstream. Signed-off-by: Matt Moyer --- .../concierge_credentialrequest_test.go | 2 +- test/library/client.go | 44 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 8e3e6a99..5346e6c4 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -59,7 +59,7 @@ func TestSuccessfulCredentialRequest(t *testing.T) { }, { name: "jwt authenticator", - authenticator: library.CreateTestJWTAuthenticator, + authenticator: library.CreateTestJWTAuthenticatorForCLIUpstream, token: func(t *testing.T) (string, string, []string) { pinnipedExe := library.PinnipedCLIPath(t) credOutput, _ := runPinnipedLoginOIDC(ctx, t, pinnipedExe) diff --git a/test/library/client.go b/test/library/client.go index 2a46af2d..e220eb70 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -168,13 +168,35 @@ func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.Ty } } -// CreateTestJWTAuthenticator creates and returns a test JWTAuthenticator in +// CreateTestJWTAuthenticatorForCLIUpstream creates and returns a test JWTAuthenticator in // $PINNIPED_TEST_CONCIERGE_NAMESPACE, which will be automatically deleted at the end of the current // test's lifetime. It returns a corev1.TypedLocalObjectReference which describes the test JWT // authenticator within the test namespace. // -// CreateTestJWTAuthenticator gets the OIDC issuer info from IntegrationEnv().CLITestUpstream. -func CreateTestJWTAuthenticator(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { +// CreateTestJWTAuthenticatorForCLIUpstream gets the OIDC issuer info from IntegrationEnv().CLITestUpstream. +func CreateTestJWTAuthenticatorForCLIUpstream(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { + t.Helper() + testEnv := IntegrationEnv(t) + spec := auth1alpha1.JWTAuthenticatorSpec{ + Issuer: testEnv.CLITestUpstream.Issuer, + Audience: testEnv.CLITestUpstream.ClientID, + } + // If the test upstream does not have a CA bundle specified, then don't configure one in the + // JWTAuthenticator. Leaving TLSSpec set to nil will result in OIDC discovery using the OS's root + // CA store. + if testEnv.CLITestUpstream.CABundle != "" { + spec.TLS = &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(testEnv.CLITestUpstream.CABundle)), + } + } + return CreateTestJWTAuthenticator(ctx, t, spec) +} + +// CreateTestJWTAuthenticator creates and returns a test JWTAuthenticator in +// $PINNIPED_TEST_CONCIERGE_NAMESPACE, which will be automatically deleted at the end of the current +// test's lifetime. It returns a corev1.TypedLocalObjectReference which describes the test JWT +// authenticator within the test namespace. +func CreateTestJWTAuthenticator(ctx context.Context, t *testing.T, spec auth1alpha1.JWTAuthenticatorSpec) corev1.TypedLocalObjectReference { t.Helper() testEnv := IntegrationEnv(t) @@ -184,23 +206,9 @@ func CreateTestJWTAuthenticator(ctx context.Context, t *testing.T) corev1.TypedL createContext, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - // If the test upstream does not have a CA bundle specified, then don't configure one in the - // JWTAuthenticator. Leaving TLSSpec set to nil will result in OIDC discovery using the OS's root - // CA store. - tlsSpec := &auth1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(testEnv.CLITestUpstream.CABundle)), - } - if testEnv.CLITestUpstream.CABundle == "" { - tlsSpec = nil - } - jwtAuthenticator, err := jwtAuthenticators.Create(createContext, &auth1alpha1.JWTAuthenticator{ ObjectMeta: testObjectMeta(t, "jwt-authenticator"), - Spec: auth1alpha1.JWTAuthenticatorSpec{ - Issuer: testEnv.CLITestUpstream.Issuer, - Audience: testEnv.CLITestUpstream.ClientID, - TLS: tlsSpec, - }, + Spec: spec, }, metav1.CreateOptions{}) require.NoError(t, err, "could not create test JWTAuthenticator") t.Logf("created test JWTAuthenticator %s/%s", jwtAuthenticator.Namespace, jwtAuthenticator.Name) From ff49647de461085a68dd75d1b74cca727769a051 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 12:26:33 -0600 Subject: [PATCH 15/16] Add some missing test logs in test/library/client.go. Signed-off-by: Matt Moyer --- test/library/client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/library/client.go b/test/library/client.go index e220eb70..0f622b44 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -309,6 +309,7 @@ func CreateTestSecret(t *testing.T, namespace string, baseName string, secretTyp require.NoError(t, err) t.Cleanup(func() { + t.Logf("cleaning up test Secret %s/%s", created.Namespace, created.Name) err := client.CoreV1().Secrets(namespace).Delete(context.Background(), created.Name, metav1.DeleteOptions{}) require.NoError(t, err) }) @@ -321,7 +322,7 @@ func CreateClientCredsSecret(t *testing.T, clientID string, clientSecret string) env := IntegrationEnv(t) return CreateTestSecret(t, env.SupervisorNamespace, - "test-client-creds", + "client-creds", "secrets.pinniped.dev/oidc-client", map[string]string{ "clientID": clientID, @@ -348,6 +349,7 @@ func CreateTestUpstreamOIDCProvider(t *testing.T, spec idpv1alpha1.UpstreamOIDCP // Always clean this up after this point. t.Cleanup(func() { + t.Logf("cleaning up test UpstreamOIDCProvider %s/%s", created.Namespace, created.Name) err := upstreams.Delete(context.Background(), created.Name, metav1.DeleteOptions{}) require.NoError(t, err) }) From 0b38d6c7635e84c2388ed736ddbed86ef0085220 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 15 Dec 2020 12:26:54 -0600 Subject: [PATCH 16/16] Add TestE2EFullIntegration test which combines supervisor, concierge, and CLI. Signed-off-by: Matt Moyer --- test/integration/e2e_test.go | 258 +++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 test/integration/e2e_test.go diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go new file mode 100644 index 00000000..1bf58c3f --- /dev/null +++ b/test/integration/e2e_test.go @@ -0,0 +1,258 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package integration + +import ( + "bufio" + "bytes" + "context" + "crypto/x509/pkix" + "encoding/base64" + "errors" + "fmt" + "io/ioutil" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + + authv1alpha "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" + configv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/config/v1alpha1" + idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/test/library" + "go.pinniped.dev/test/library/browsertest" +) + +// TestE2EFullIntegration tests a full integration scenario that combines the supervisor, concierge, and CLI. +func TestE2EFullIntegration(t *testing.T) { + env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) + ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancelFunc() + + // Build pinniped CLI. + pinnipedExe := library.PinnipedCLIPath(t) + tempDir := testutil.TempDir(t) + + // Start the browser driver. + page := browsertest.Open(t) + + // Infer the downstream issuer URL from the callback associated with the upstream test client registration. + issuerURL, err := url.Parse(env.SupervisorTestUpstream.CallbackURL) + require.NoError(t, err) + require.True(t, strings.HasSuffix(issuerURL.Path, "/callback")) + issuerURL.Path = strings.TrimSuffix(issuerURL.Path, "/callback") + t.Logf("testing with downstream issuer URL %s", issuerURL.String()) + + // Generate a CA bundle with which to serve this provider. + t.Logf("generating test CA") + ca, err := certauthority.New(pkix.Name{CommonName: "Downstream Test CA"}, 1*time.Hour) + require.NoError(t, err) + + // Save that bundle plus the one that signs the upstream issuer, for test purposes. + testCABundlePath := filepath.Join(tempDir, "test-ca.pem") + testCABundlePEM := []byte(string(ca.Bundle()) + "\n" + env.SupervisorTestUpstream.CABundle) + testCABundleBase64 := base64.StdEncoding.EncodeToString(testCABundlePEM) + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCABundlePEM, 0600)) + + // Use the CA to issue a TLS server cert. + t.Logf("issuing test certificate") + tlsCert, err := ca.Issue( + pkix.Name{CommonName: issuerURL.Hostname()}, + []string{issuerURL.Hostname()}, + nil, + 1*time.Hour, + ) + require.NoError(t, err) + certPEM, keyPEM, err := certauthority.ToPEM(tlsCert) + require.NoError(t, err) + + // Write the serving cert to a secret. + certSecret := library.CreateTestSecret(t, + env.SupervisorNamespace, + "oidc-provider-tls", + "kubernetes.io/tls", + map[string]string{"tls.crt": string(certPEM), "tls.key": string(keyPEM)}, + ) + + // Create the downstream OIDCProvider and expect it to go into the success status condition. + downstream := library.CreateTestOIDCProvider(ctx, t, + issuerURL.String(), + certSecret.Name, + configv1alpha1.SuccessOIDCProviderStatusCondition, + ) + + // Create upstream OIDC provider and wait for it to become ready. + library.CreateTestUpstreamOIDCProvider(t, idpv1alpha1.UpstreamOIDCProviderSpec{ + Issuer: env.SupervisorTestUpstream.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)), + }, + AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: []string{"email"}, + }, + Claims: idpv1alpha1.OIDCClaims{ + Username: "email", + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: library.CreateClientCredsSecret(t, env.SupervisorTestUpstream.ClientID, env.SupervisorTestUpstream.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + + // Create a JWTAuthenticator that will validate the tokens from the downstream issuer. + clusterAudience := "test-cluster-" + library.RandHex(t, 8) + authenticator := library.CreateTestJWTAuthenticator(ctx, t, authv1alpha.JWTAuthenticatorSpec{ + Issuer: downstream.Spec.Issuer, + Audience: clusterAudience, + TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64}, + }) + + // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. + library.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: env.SupervisorTestUpstream.Username}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, + ) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/sessions.yaml" + + // Run "pinniped get kubeconfig" to get a kubeconfig YAML. + kubeconfigYAML, stderr := runPinnipedCLI(t, pinnipedExe, "get", "kubeconfig", + "--concierge-namespace", env.ConciergeNamespace, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", sessionCachePath, + ) + require.Equal(t, "", stderr) + + restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML) + require.NotNil(t, restConfig.ExecProvider) + require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2]) + kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml") + require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600)) + + // Wait 10 seconds for the JWTAuthenticator to become initialized. + // TODO: remove this sleep once we have fixed the initialization problem. + t.Log("sleeping 10s to wait for JWTAuthenticator to become initialized") + time.Sleep(10 * time.Second) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + start := time.Now() + kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + stderrPipe, err := kubectlCmd.StderrPipe() + require.NoError(t, err) + stdoutPipe, err := kubectlCmd.StdoutPipe() + require.NoError(t, err) + + t.Logf("starting kubectl subprocess") + require.NoError(t, kubectlCmd.Start()) + t.Cleanup(func() { + err := kubectlCmd.Wait() + t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) + require.NoErrorf(t, err, "kubectl process did not exit cleanly") + }) + + // Start a background goroutine to read stderr from the CLI and parse out the login URL. + loginURLChan := make(chan string) + spawnTestGoroutine(t, func() (err error) { + defer func() { + closeErr := stderrPipe.Close() + if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { + return + } + if err == nil { + err = fmt.Errorf("stderr stream closed with error: %w", closeErr) + } + }() + + reader := bufio.NewReader(library.NewLoggerReader(t, "stderr", stderrPipe)) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("could not read login URL line from stderr: %w", err) + } + const prompt = "Please log in: " + if !strings.HasPrefix(line, prompt) { + return fmt.Errorf("expected %q to have prefix %q", line, prompt) + } + loginURLChan <- strings.TrimPrefix(line, prompt) + return readAndExpectEmpty(reader) + }) + + // Start a background goroutine to read stdout from kubectl and return the result as a string. + kubectlOutputChan := make(chan string) + spawnTestGoroutine(t, func() (err error) { + defer func() { + closeErr := stdoutPipe.Close() + if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { + return + } + if err == nil { + err = fmt.Errorf("stdout stream closed with error: %w", closeErr) + } + }() + output, err := ioutil.ReadAll(stdoutPipe) + if err != nil { + return err + } + t.Logf("kubectl output:\n%s\n", output) + kubectlOutputChan <- string(output) + return nil + }) + + // Wait for the CLI to print out the login URL and open the browser to it. + t.Logf("waiting for CLI to output login URL") + var loginURL string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for login URL") + case loginURL = <-loginURLChan: + } + t.Logf("navigating to login page") + require.NoError(t, page.Navigate(loginURL)) + + // Expect to be redirected to the upstream provider and log in. + browsertest.LoginToUpstream(t, page, env.SupervisorTestUpstream) + + // Expect to be redirected to the localhost callback. + t.Logf("waiting for redirect to callback") + browsertest.WaitForURL(t, page, regexp.MustCompile(`\Ahttp://127\.0\.0\.1:[0-9]+/callback\?.+\z`)) + + // Wait for the "pre" element that gets rendered for a `text/plain` page, and + // assert that it contains the success message. + t.Logf("verifying success page") + browsertest.WaitForVisibleElements(t, page, "pre") + msg, err := page.First("pre").Text() + require.NoError(t, err) + require.Equal(t, "you have been logged in and may now close this tab", msg) + + // Expect the CLI to output a list of namespaces in JSON format. + t.Logf("waiting for kubectl to output namespace list JSON") + var kubectlOutput string + select { + case <-time.After(10 * time.Second): + require.Fail(t, "timed out waiting for kubectl output") + case kubectlOutput = <-kubectlOutputChan: + } + require.Greaterf(t, len(strings.Split(kubectlOutput, "\n")), 2, "expected some namespaces to be returned") + t.Logf("first kubectl command took %s", time.Since(start).String()) + + // Run kubectl again, which should work with no browser interaction. + kubectlCmd2 := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) + kubectlCmd2.Env = append(os.Environ(), env.ProxyEnv()...) + start = time.Now() + kubectlOutput2, err := kubectlCmd2.CombinedOutput() + require.NoError(t, err) + require.Greaterf(t, len(bytes.Split(kubectlOutput2, []byte("\n"))), 2, "expected some namespaces to be returned again") + t.Logf("second kubectl command took %s", time.Since(start).String()) +}