Add IDP selector support in client code.

Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
Matt Moyer 2020-09-17 17:11:47 -05:00
parent 164f64a370
commit fbe0551426
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D
5 changed files with 104 additions and 24 deletions

View File

@ -9,11 +9,14 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strings"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" 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/client"
"go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/here" "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 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) { func runExchangeCredential(stdout, _ io.Writer) {
err := exchangeCredential(os.LookupEnv, client.ExchangeToken, stdout, 30*time.Second) 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") 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") token, varExists := envGetter("PINNIPED_TOKEN")
if !varExists { if !varExists {
return envVarNotSetError("PINNIPED_TOKEN") return envVarNotSetError("PINNIPED_TOKEN")
@ -111,7 +134,16 @@ func exchangeCredential(envGetter envGetter, tokenExchanger tokenExchanger, outp
return envVarNotSetError("PINNIPED_K8S_API_ENDPOINT") 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 { if err != nil {
return fmt.Errorf("failed to get credential: %w", err) return fmt.Errorf("failed to get credential: %w", err)
} }

View File

@ -14,6 +14,7 @@ import (
"github.com/sclevine/spec" "github.com/sclevine/spec"
"github.com/sclevine/spec/report" "github.com/sclevine/spec/report"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
@ -136,6 +137,8 @@ func TestExchangeCredential(t *testing.T) {
buffer = new(bytes.Buffer) buffer = new(bytes.Buffer)
fakeEnv = map[string]string{ fakeEnv = map[string]string{
"PINNIPED_NAMESPACE": "namespace from env", "PINNIPED_NAMESPACE": "namespace from env",
"PINNIPED_IDP_TYPE": "Webhook",
"PINNIPED_IDP_NAME": "webhook name from env",
"PINNIPED_TOKEN": "token from env", "PINNIPED_TOKEN": "token from env",
"PINNIPED_CA_BUNDLE": "ca bundle from env", "PINNIPED_CA_BUNDLE": "ca bundle from env",
"PINNIPED_K8S_API_ENDPOINT": "k8s api 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") 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() { it("returns an error when PINNIPED_TOKEN is missing", func() {
delete(fakeEnv, "PINNIPED_TOKEN") delete(fakeEnv, "PINNIPED_TOKEN")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) 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() { when("the token exchange fails", func() {
it.Before(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") return nil, fmt.Errorf("some error")
} }
}) })
@ -183,7 +206,7 @@ func TestExchangeCredential(t *testing.T) {
when("the JSON encoder fails", func() { when("the JSON encoder fails", func() {
it.Before(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{ return &clientauthenticationv1beta1.ExecCredential{
Status: &clientauthenticationv1beta1.ExecCredentialStatus{ Status: &clientauthenticationv1beta1.ExecCredentialStatus{
Token: "some token", Token: "some token",
@ -200,7 +223,7 @@ func TestExchangeCredential(t *testing.T) {
when("the token exchange times out", func() { when("the token exchange times out", func() {
it.Before(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 { select {
case <-time.After(100 * time.Millisecond): case <-time.After(100 * time.Millisecond):
return &clientauthenticationv1beta1.ExecCredential{ return &clientauthenticationv1beta1.ExecCredential{
@ -224,7 +247,7 @@ func TestExchangeCredential(t *testing.T) {
var actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint string var actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint string
it.Before(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) {
actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint = namespace, token, caBundle, apiEndpoint actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint = namespace, token, caBundle, apiEndpoint
now := metav1.NewTime(time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC)) now := metav1.NewTime(time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC))
return &clientauthenticationv1beta1.ExecCredential{ return &clientauthenticationv1beta1.ExecCredential{

View File

@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
@ -21,15 +22,18 @@ import (
// ErrLoginFailed is returned by ExchangeToken when the server rejects the login request. // ErrLoginFailed is returned by ExchangeToken when the server rejects the login request.
var ErrLoginFailed = errors.New("login failed") 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. // 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, token string, caBundle string, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { func ExchangeToken(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token string, caBundle string, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) {
client, err := getClient(apiEndpoint, caBundle) client, err := getClient(apiEndpoint, caBundle)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get API client: %w", err) return nil, fmt.Errorf("could not get API client: %w", err)
} }
resp, err := client.LoginV1alpha1().TokenCredentialRequests(namespace).Create(ctx, &v1alpha1.TokenCredentialRequest{ resp, err := client.LoginV1alpha1().TokenCredentialRequests(namespace).Create(ctx, &v1alpha1.TokenCredentialRequest{
Spec: v1alpha1.TokenCredentialRequestSpec{Token: token}, Spec: v1alpha1.TokenCredentialRequestSpec{
Token: token,
IdentityProvider: idp,
},
}, metav1.CreateOptions{}) }, metav1.CreateOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("could not login: %w", err) return nil, fmt.Errorf("could not login: %w", err)

View File

@ -12,10 +12,12 @@ import (
"time" "time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" 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" "go.pinniped.dev/internal/testutil"
) )
@ -23,9 +25,15 @@ func TestExchangeToken(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
testIDP := corev1.TypedLocalObjectReference{
APIGroup: &idpv1alpha1.SchemeGroupVersion.Group,
Kind: "WebhookIdentityProvider",
Name: "test-webhook",
}
t.Run("invalid configuration", func(t *testing.T) { t.Run("invalid configuration", func(t *testing.T) {
t.Parallel() 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.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) require.Nil(t, got)
}) })
@ -38,7 +46,7 @@ func TestExchangeToken(t *testing.T) {
_, _ = w.Write([]byte("some server error")) _, _ = 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.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) require.Nil(t, got)
}) })
@ -49,13 +57,13 @@ func TestExchangeToken(t *testing.T) {
errorMessage := "some login failure" errorMessage := "some login failure"
caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json") 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"}, 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.EqualError(t, err, `login failed: some login failure`)
require.Nil(t, got) 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 // 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) { caBundle, endpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json") 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"}, 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.EqualError(t, err, `login failed: unknown`)
require.Nil(t, got) require.Nil(t, got)
}) })
@ -95,7 +103,12 @@ func TestExchangeToken(t *testing.T) {
"creationTimestamp": null "creationTimestamp": null
}, },
"spec": { "spec": {
"token": "test-token" "token": "test-token",
"identityProvider": {
"apiGroup": "idp.pinniped.dev",
"kind": "WebhookIdentityProvider",
"name": "test-webhook"
}
}, },
"status": {} "status": {}
}`, }`,
@ -103,10 +116,10 @@ func TestExchangeToken(t *testing.T) {
) )
w.Header().Set("content-type", "application/json") 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"}, TypeMeta: metav1.TypeMeta{APIVersion: "login.pinniped.dev/v1alpha1", Kind: "TokenCredentialRequest"},
Status: v1alpha1.TokenCredentialRequestStatus{ Status: loginv1alpha1.TokenCredentialRequestStatus{
Credential: &v1alpha1.ClusterCredential{ Credential: &loginv1alpha1.ClusterCredential{
ExpirationTimestamp: expires, ExpirationTimestamp: expires,
ClientCertificateData: "test-certificate", ClientCertificateData: "test-certificate",
ClientKeyData: "test-key", 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.NoError(t, err)
require.Equal(t, &clientauthenticationv1beta1.ExecCredential{ require.Equal(t, &clientauthenticationv1beta1.ExecCredential{
TypeMeta: metav1.TypeMeta{ TypeMeta: metav1.TypeMeta{

View File

@ -10,7 +10,9 @@ import (
"time" "time"
"github.com/stretchr/testify/require" "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/client"
"go.pinniped.dev/internal/here" "go.pinniped.dev/internal/here"
"go.pinniped.dev/test/library" "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. // Using the CA bundle and host from the current (admin) kubeconfig, do the token exchange.
clientConfig := library.NewClientConfig(t) 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.NoError(t, err)
require.NotNil(t, resp.Status.ExpirationTimestamp) require.NotNil(t, resp.Status.ExpirationTimestamp)
require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute)) require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute))