From fbe055142627462678dad30072df2e9d35a63d23 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Thu, 17 Sep 2020 17:11:47 -0500 Subject: [PATCH] Add IDP selector support in client code. Signed-off-by: Matt Moyer --- cmd/pinniped/cmd/exchange_credential.go | 38 +++++++++++++++++-- cmd/pinniped/cmd/exchange_credential_test.go | 31 ++++++++++++++-- internal/client/client.go | 10 +++-- internal/client/client_test.go | 39 +++++++++++++------- test/integration/client_test.go | 10 ++++- 5 files changed, 104 insertions(+), 24 deletions(-) diff --git a/cmd/pinniped/cmd/exchange_credential.go b/cmd/pinniped/cmd/exchange_credential.go index 33e2ddb15..1ad0470ff 100644 --- a/cmd/pinniped/cmd/exchange_credential.go +++ b/cmd/pinniped/cmd/exchange_credential.go @@ -9,11 +9,14 @@ import ( "fmt" "io" "os" + "strings" "time" "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/idp/v1alpha1" "go.pinniped.dev/internal/client" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/here" @@ -75,9 +78,19 @@ func newExchangeCredentialCmd(args []string, stdout, stderr io.Writer) *exchange } type envGetter func(string) (string, bool) -type tokenExchanger func(ctx context.Context, namespace, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) +type tokenExchanger func( + ctx context.Context, + namespace string, + idp corev1.TypedLocalObjectReference, + token string, + caBundle string, + apiEndpoint string, +) (*clientauthenticationv1beta1.ExecCredential, error) -const ErrMissingEnvVar = constable.Error("failed to get credential: environment variable not set") +const ( + ErrMissingEnvVar = constable.Error("failed to get credential: environment variable not set") + ErrInvalidIDPType = constable.Error("invalid IDP type") +) func runExchangeCredential(stdout, _ io.Writer) { err := exchangeCredential(os.LookupEnv, client.ExchangeToken, stdout, 30*time.Second) @@ -96,6 +109,16 @@ func exchangeCredential(envGetter envGetter, tokenExchanger tokenExchanger, outp return envVarNotSetError("PINNIPED_NAMESPACE") } + idpType, varExists := envGetter("PINNIPED_IDP_TYPE") + if !varExists { + return envVarNotSetError("PINNIPED_IDP_TYPE") + } + + idpName, varExists := envGetter("PINNIPED_IDP_NAME") + if !varExists { + return envVarNotSetError("PINNIPED_IDP_NAME") + } + token, varExists := envGetter("PINNIPED_TOKEN") if !varExists { return envVarNotSetError("PINNIPED_TOKEN") @@ -111,7 +134,16 @@ func exchangeCredential(envGetter envGetter, tokenExchanger tokenExchanger, outp return envVarNotSetError("PINNIPED_K8S_API_ENDPOINT") } - cred, err := tokenExchanger(ctx, namespace, token, caBundle, apiEndpoint) + idp := corev1.TypedLocalObjectReference{Name: idpName} + switch strings.ToLower(idpType) { + case "webhook": + idp.APIGroup = &idpv1alpha1.SchemeGroupVersion.Group + idp.Kind = "WebhookIdentityProvider" + default: + return fmt.Errorf(`%w: %q, supported values are "webhook"`, ErrInvalidIDPType, idpType) + } + + cred, err := tokenExchanger(ctx, namespace, idp, token, caBundle, apiEndpoint) if err != nil { return fmt.Errorf("failed to get credential: %w", err) } diff --git a/cmd/pinniped/cmd/exchange_credential_test.go b/cmd/pinniped/cmd/exchange_credential_test.go index eb7478f0e..1d74f186d 100644 --- a/cmd/pinniped/cmd/exchange_credential_test.go +++ b/cmd/pinniped/cmd/exchange_credential_test.go @@ -14,6 +14,7 @@ import ( "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" @@ -136,6 +137,8 @@ func TestExchangeCredential(t *testing.T) { buffer = new(bytes.Buffer) fakeEnv = map[string]string{ "PINNIPED_NAMESPACE": "namespace from env", + "PINNIPED_IDP_TYPE": "Webhook", + "PINNIPED_IDP_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", @@ -149,6 +152,18 @@ func TestExchangeCredential(t *testing.T) { r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_NAMESPACE") }) + it("returns an error when PINNIPED_IDP_TYPE is missing", func() { + delete(fakeEnv, "PINNIPED_IDP_TYPE") + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) + r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_IDP_TYPE") + }) + + it("returns an error when PINNIPED_IDP_NAME is missing", func() { + delete(fakeEnv, "PINNIPED_IDP_NAME") + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) + r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_IDP_NAME") + }) + it("returns an error when PINNIPED_TOKEN is missing", func() { delete(fakeEnv, "PINNIPED_TOKEN") err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) @@ -168,9 +183,17 @@ func TestExchangeCredential(t *testing.T) { }) }) + when("env vars are invalid", func() { + it("returns an error when PINNIPED_IDP_TYPE is missing", func() { + fakeEnv["PINNIPED_IDP_TYPE"] = "invalid" + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) + r.EqualError(err, `invalid IDP type: "invalid", supported values are "webhook"`) + }) + }) + when("the token exchange fails", func() { it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { + tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { return nil, fmt.Errorf("some error") } }) @@ -183,7 +206,7 @@ func TestExchangeCredential(t *testing.T) { when("the JSON encoder fails", func() { it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { + tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { return &clientauthenticationv1beta1.ExecCredential{ Status: &clientauthenticationv1beta1.ExecCredentialStatus{ Token: "some token", @@ -200,7 +223,7 @@ func TestExchangeCredential(t *testing.T) { when("the token exchange times out", func() { it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { + tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { select { case <-time.After(100 * time.Millisecond): return &clientauthenticationv1beta1.ExecCredential{ @@ -224,7 +247,7 @@ func TestExchangeCredential(t *testing.T) { var actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint string it.Before(func() { - tokenExchanger = func(ctx context.Context, namespace, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { + tokenExchanger = func(ctx context.Context, namespace string, idp 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{ diff --git a/internal/client/client.go b/internal/client/client.go index b88af506a..06831cc84 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" + 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" @@ -21,15 +22,18 @@ import ( // ErrLoginFailed is returned by ExchangeToken when the server rejects the login request. var ErrLoginFailed = errors.New("login failed") -// ExchangeToken exchanges an opaque token using the Pinniped CredentialRequest API, returning a client-go ExecCredential valid on the target cluster. -func ExchangeToken(ctx context.Context, namespace string, token string, caBundle string, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { +// ExchangeToken exchanges an opaque token using the Pinniped TokenCredentialRequest API, returning a client-go ExecCredential valid on the target cluster. +func ExchangeToken(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token string, caBundle string, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { client, err := getClient(apiEndpoint, caBundle) if err != nil { return nil, fmt.Errorf("could not get API client: %w", err) } resp, err := client.LoginV1alpha1().TokenCredentialRequests(namespace).Create(ctx, &v1alpha1.TokenCredentialRequest{ - Spec: v1alpha1.TokenCredentialRequestSpec{Token: token}, + Spec: v1alpha1.TokenCredentialRequestSpec{ + Token: token, + IdentityProvider: idp, + }, }, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("could not login: %w", err) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 8ba5ff66e..482f588aa 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -12,10 +12,12 @@ import ( "time" "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" - "go.pinniped.dev/generated/1.19/apis/login/v1alpha1" + idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/idp/v1alpha1" + loginv1alpha1 "go.pinniped.dev/generated/1.19/apis/login/v1alpha1" "go.pinniped.dev/internal/testutil" ) @@ -23,9 +25,15 @@ func TestExchangeToken(t *testing.T) { t.Parallel() ctx := context.Background() + testIDP := corev1.TypedLocalObjectReference{ + APIGroup: &idpv1alpha1.SchemeGroupVersion.Group, + Kind: "WebhookIdentityProvider", + Name: "test-webhook", + } + t.Run("invalid configuration", func(t *testing.T) { t.Parallel() - got, err := ExchangeToken(ctx, "test-namespace", "", "", "") + got, err := ExchangeToken(ctx, "test-namespace", testIDP, "", "", "") require.EqualError(t, err, "could not get API client: invalid configuration: no configuration has been provided, try setting KUBERNETES_MASTER environment variable") require.Nil(t, got) }) @@ -38,7 +46,7 @@ func TestExchangeToken(t *testing.T) { _, _ = w.Write([]byte("some server error")) }) - got, err := ExchangeToken(ctx, "test-namespace", "", caBundle, endpoint) + got, err := ExchangeToken(ctx, "test-namespace", testIDP, "", caBundle, endpoint) require.EqualError(t, err, `could not login: an error on the server ("some server error") has prevented the request from succeeding (post tokencredentialrequests.login.pinniped.dev)`) require.Nil(t, got) }) @@ -49,13 +57,13 @@ func TestExchangeToken(t *testing.T) { 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(&v1alpha1.TokenCredentialRequest{ + _ = json.NewEncoder(w).Encode(&loginv1alpha1.TokenCredentialRequest{ TypeMeta: metav1.TypeMeta{APIVersion: "login.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"}, - Status: v1alpha1.TokenCredentialRequestStatus{Message: &errorMessage}, + Status: loginv1alpha1.TokenCredentialRequestStatus{Message: &errorMessage}, }) }) - got, err := ExchangeToken(ctx, "test-namespace", "", caBundle, endpoint) + got, err := ExchangeToken(ctx, "test-namespace", testIDP, "", caBundle, endpoint) require.EqualError(t, err, `login failed: some login failure`) require.Nil(t, got) }) @@ -65,12 +73,12 @@ func TestExchangeToken(t *testing.T) { // 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(&v1alpha1.TokenCredentialRequest{ + _ = json.NewEncoder(w).Encode(&loginv1alpha1.TokenCredentialRequest{ TypeMeta: metav1.TypeMeta{APIVersion: "login.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"}, }) }) - got, err := ExchangeToken(ctx, "test-namespace", "", caBundle, endpoint) + got, err := ExchangeToken(ctx, "test-namespace", testIDP, "", caBundle, endpoint) require.EqualError(t, err, `login failed: unknown`) require.Nil(t, got) }) @@ -95,7 +103,12 @@ func TestExchangeToken(t *testing.T) { "creationTimestamp": null }, "spec": { - "token": "test-token" + "token": "test-token", + "identityProvider": { + "apiGroup": "idp.pinniped.dev", + "kind": "WebhookIdentityProvider", + "name": "test-webhook" + } }, "status": {} }`, @@ -103,10 +116,10 @@ func TestExchangeToken(t *testing.T) { ) w.Header().Set("content-type", "application/json") - _ = json.NewEncoder(w).Encode(&v1alpha1.TokenCredentialRequest{ + _ = json.NewEncoder(w).Encode(&loginv1alpha1.TokenCredentialRequest{ TypeMeta: metav1.TypeMeta{APIVersion: "login.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"}, - Status: v1alpha1.TokenCredentialRequestStatus{ - Credential: &v1alpha1.ClusterCredential{ + Status: loginv1alpha1.TokenCredentialRequestStatus{ + Credential: &loginv1alpha1.ClusterCredential{ ExpirationTimestamp: expires, ClientCertificateData: "test-certificate", ClientKeyData: "test-key", @@ -115,7 +128,7 @@ func TestExchangeToken(t *testing.T) { }) }) - got, err := ExchangeToken(ctx, "test-namespace", "test-token", caBundle, endpoint) + got, err := ExchangeToken(ctx, "test-namespace", testIDP, "test-token", caBundle, endpoint) require.NoError(t, err) require.Equal(t, &clientauthenticationv1beta1.ExecCredential{ TypeMeta: metav1.TypeMeta{ diff --git a/test/integration/client_test.go b/test/integration/client_test.go index 9992126d0..1c451e2d1 100644 --- a/test/integration/client_test.go +++ b/test/integration/client_test.go @@ -10,7 +10,9 @@ import ( "time" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/idp/v1alpha1" "go.pinniped.dev/internal/client" "go.pinniped.dev/internal/here" "go.pinniped.dev/test/library" @@ -68,7 +70,13 @@ func TestClient(t *testing.T) { // Using the CA bundle and host from the current (admin) kubeconfig, do the token exchange. clientConfig := library.NewClientConfig(t) - resp, err := client.ExchangeToken(ctx, namespace, token, string(clientConfig.CAData), clientConfig.Host) + + idp := corev1.TypedLocalObjectReference{ + APIGroup: &idpv1alpha1.SchemeGroupVersion.Group, + Kind: "WebhookIdentityProvider", + Name: "pinniped-webhook", + } + resp, err := client.ExchangeToken(ctx, namespace, idp, token, string(clientConfig.CAData), clientConfig.Host) require.NoError(t, err) require.NotNil(t, resp.Status.ExpirationTimestamp) require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute))