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 <moyerm@vmware.com>
This commit is contained in:
parent
08cf2f7cd1
commit
9b7fe01648
193
pkg/conciergeclient/conciergeclient.go
Normal file
193
pkg/conciergeclient/conciergeclient.go
Normal file
@ -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
|
||||
}
|
263
pkg/conciergeclient/conciergeclient_test.go
Normal file
263
pkg/conciergeclient/conciergeclient_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user