From 9b7fe01648e3168be6746e2a9595a4c20fff3f71 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Fri, 11 Dec 2020 15:28:19 -0600 Subject: [PATCH] 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) + }) +}