diff --git a/pkg/client/client.go b/pkg/client/client.go index 4f00eaea..b9f372f6 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -7,11 +7,76 @@ package client import ( "context" + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/pkg/apis/clientauthentication" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1" + placeholderclientset "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned" + "github.com/suzerain-io/placeholder-name/internal/constable" ) +// ErrLoginFailed is returned by ExchangeToken when the server rejects the login request. +const ErrLoginFailed = constable.Error("login failed") + func ExchangeToken(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthentication.ExecCredential, error) { - _, _, _, _ = ctx, token, caBundle, apiEndpoint - return nil, nil + clientset, err := getClient(apiEndpoint, caBundle) + if err != nil { + return nil, fmt.Errorf("could not get API client: %w", err) + } + + resp, err := clientset.PlaceholderV1alpha1().LoginRequests().Create(ctx, &placeholderv1alpha1.LoginRequest{ + Spec: placeholderv1alpha1.LoginRequestSpec{ + Type: placeholderv1alpha1.TokenLoginCredentialType, + Token: &placeholderv1alpha1.LoginRequestTokenCredential{ + Value: token, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("could not login: %w", err) + } + if resp.Status.Credential == nil || resp.Status.Message != "" { + return nil, fmt.Errorf("%w: %s", ErrLoginFailed, resp.Status.Message) + } + + return &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ExpirationTimestamp: resp.Status.Credential.ExpirationTimestamp, + ClientCertificateData: resp.Status.Credential.ClientCertificateData, + ClientKeyData: resp.Status.Credential.ClientKeyData, + }, + }, nil +} + +// getClient returns an anonymous clientset for the placeholder-name API at the provided endpoint/CA bundle. +func getClient(apiEndpoint string, caBundle string) (placeholderclientset.Interface, error) { + cfg, err := clientcmd.NewNonInteractiveClientConfig(clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "cluster": { + Server: apiEndpoint, + CertificateAuthorityData: []byte(caBundle), + }, + }, + Contexts: map[string]*clientcmdapi.Context{ + "current": { + Cluster: "cluster", + AuthInfo: "client", + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "client": {}, + }, + }, "current", nil, nil).ClientConfig() + if err != nil { + return nil, err + } + return placeholderclientset.NewForConfig(cfg) } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 00000000..120025df --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,121 @@ +package client + +import ( + "context" + "encoding/json" + "encoding/pem" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/pkg/apis/clientauthentication" + + placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1" +) + +func startTestServer(t *testing.T, handler http.HandlerFunc) (string, string) { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + + caBundle := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: server.TLS.Certificates[0].Certificate[0], + })) + return caBundle, server.URL +} + +func TestExchangeToken(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("invalid configuration", func(t *testing.T) { + t.Parallel() + got, err := ExchangeToken(ctx, "", "", "") + 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) + }) + + t.Run("server error", func(t *testing.T) { + t.Parallel() + // Start a test server that returns only 500 errors. + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("some server error")) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.EqualError(t, err, `could not login: an error on the server ("some server error") has prevented the request from succeeding (post loginrequests.placeholder.suzerain-io.github.io)`) + 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 + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&placeholderv1alpha1.LoginRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "placeholder.suzerain-io.github.io/v1alpha1", Kind: "LoginRequest"}, + Status: placeholderv1alpha1.LoginRequestStatus{Message: "some login failure"}, + }) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.EqualError(t, err, `login failed: some login failure`) + require.Nil(t, got) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + // Start a test server that returns successfully and asserts various properties of the request. + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "application/json", r.Header.Get("content-type")) + + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + require.JSONEq(t, + `{ + "kind": "LoginRequest", + "apiVersion": "placeholder.suzerain-io.github.io/v1alpha1", + "metadata": { + "creationTimestamp": null + }, + "spec": { + "type": "token", + "token": {} + }, + "status": {} + }`, + string(body), + ) + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&placeholderv1alpha1.LoginRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "placeholder.suzerain-io.github.io/v1alpha1", Kind: "LoginRequest"}, + Status: placeholderv1alpha1.LoginRequestStatus{ + Credential: &placeholderv1alpha1.LoginRequestCredential{ + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + }, + }, + }) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.NoError(t, err) + require.Equal(t, &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + }, + }, got) + }) +}