From 71850419c17a698e9f483c62c64079c403a157ee Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Mon, 14 Dec 2020 18:38:19 -0600 Subject: [PATCH] Overhaul `pinniped` CLI subcommands. - Adds two new subcommands: `pinniped get kubeconfig` and `pinniped login static` - Adds concierge support to `pinniped login oidc`. - Adds back wrapper commands for the now deprecated `pinniped get-kubeconfig` and `pinniped exchange-credential` commands. These now wrap `pinniped get kubeconfig` and `pinniped login static` respectively. Signed-off-by: Matt Moyer --- cmd/pinniped/cmd/deprecated.go | 136 +++++++ cmd/pinniped/cmd/get.go | 20 + cmd/pinniped/cmd/kubeconfig.go | 364 ++++++++++++++++++ cmd/pinniped/cmd/kubeconfig_test.go | 524 ++++++++++++++++++++++++++ cmd/pinniped/cmd/login.go | 3 +- cmd/pinniped/cmd/login_oidc.go | 261 ++++++++----- cmd/pinniped/cmd/login_oidc_test.go | 176 +++++++-- cmd/pinniped/cmd/login_static.go | 120 ++++++ cmd/pinniped/cmd/login_static_test.go | 180 +++++++++ 9 files changed, 1661 insertions(+), 123 deletions(-) create mode 100644 cmd/pinniped/cmd/deprecated.go create mode 100644 cmd/pinniped/cmd/get.go create mode 100644 cmd/pinniped/cmd/kubeconfig.go create mode 100644 cmd/pinniped/cmd/kubeconfig_test.go create mode 100644 cmd/pinniped/cmd/login_static.go create mode 100644 cmd/pinniped/cmd/login_static_test.go diff --git a/cmd/pinniped/cmd/deprecated.go b/cmd/pinniped/cmd/deprecated.go new file mode 100644 index 000000000..8894eef46 --- /dev/null +++ b/cmd/pinniped/cmd/deprecated.go @@ -0,0 +1,136 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/plog" +) + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(legacyGetKubeconfigCommand(kubeconfigRealDeps())) + rootCmd.AddCommand(legacyExchangeTokenCommand(staticLoginRealDeps())) +} + +func legacyGetKubeconfigCommand(deps kubeconfigDeps) *cobra.Command { + var ( + cmd = &cobra.Command{ + Hidden: true, + Deprecated: "Please use `pinniped get kubeconfig` instead.", + + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "get-kubeconfig", + Short: "Print a kubeconfig for authenticating into a cluster via Pinniped", + Long: here.Doc(` + Print a kubeconfig for authenticating into a cluster via Pinniped. + Requires admin-like access to the cluster using the current + kubeconfig context in order to access Pinniped's metadata. + The current kubeconfig is found similar to how kubectl finds it: + using the value of the --kubeconfig option, or if that is not + specified then from the value of the KUBECONFIG environment + variable, or if that is not specified then it defaults to + .kube/config in your home directory. + Prints a kubeconfig which is suitable to access the cluster using + Pinniped as the authentication mechanism. This kubeconfig output + can be saved to a file and used with future kubectl commands, e.g.: + pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig + kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods + `), + } + token string + kubeconfig string + contextOverride string + namespace string + authenticatorType string + authenticatorName string + ) + + cmd.Flags().StringVar(&token, "token", "", "Credential to include in the resulting kubeconfig output (Required)") + cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to the kubeconfig file") + cmd.Flags().StringVar(&contextOverride, "kubeconfig-context", "", "Kubeconfig context override") + cmd.Flags().StringVar(&namespace, "pinniped-namespace", "pinniped-concierge", "Namespace in which Pinniped was installed") + cmd.Flags().StringVar(&authenticatorType, "authenticator-type", "", "Authenticator type (e.g., 'webhook', 'jwt')") + cmd.Flags().StringVar(&authenticatorName, "authenticator-name", "", "Authenticator name") + mustMarkRequired(cmd, "token") + plog.RemoveKlogGlobalFlags() + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runGetKubeconfig(cmd.OutOrStdout(), deps, getKubeconfigParams{ + kubeconfigPath: kubeconfig, + kubeconfigContextOverride: contextOverride, + staticToken: token, + concierge: getKubeconfigConciergeParams{ + namespace: namespace, + authenticatorName: authenticatorName, + authenticatorType: authenticatorType, + }, + }) + } + return cmd +} + +func legacyExchangeTokenCommand(deps staticLoginDeps) *cobra.Command { + cmd := &cobra.Command{ + Hidden: true, + Deprecated: "Please use `pinniped login static` instead.", + + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "exchange-credential", + Short: "Exchange a credential for a cluster-specific access credential", + Long: here.Doc(` + Exchange a credential which proves your identity for a time-limited, + cluster-specific access credential. + Designed to be conveniently used as an credential plugin for kubectl. + See the help message for 'pinniped get-kubeconfig' for more + information about setting up a kubeconfig file using Pinniped. + Requires all of the following environment variables, which are + typically set in the kubeconfig: + - PINNIPED_TOKEN: the token to send to Pinniped for exchange + - PINNIPED_NAMESPACE: the namespace of the authenticator to authenticate + against + - PINNIPED_AUTHENTICATOR_TYPE: the type of authenticator to authenticate + against (e.g., "webhook", "jwt") + - PINNIPED_AUTHENTICATOR_NAME: the name of the authenticator to authenticate + against + - PINNIPED_CA_BUNDLE: the CA bundle to trust when calling + Pinniped's HTTPS endpoint + - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential + exchange API + For more information about credential plugins in general, see + https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins + `), + } + plog.RemoveKlogGlobalFlags() + cmd.RunE = func(cmd *cobra.Command, args []string) error { + // Make a little helper to grab OS environment variables and keep a list that were missing. + var missing []string + getEnv := func(name string) string { + value, ok := os.LookupEnv(name) + if !ok { + missing = append(missing, name) + } + return value + } + flags := staticLoginParams{ + staticToken: getEnv("PINNIPED_TOKEN"), + conciergeEnabled: true, + conciergeNamespace: getEnv("PINNIPED_NAMESPACE"), + conciergeAuthenticatorType: getEnv("PINNIPED_AUTHENTICATOR_TYPE"), + conciergeAuthenticatorName: getEnv("PINNIPED_AUTHENTICATOR_NAME"), + conciergeEndpoint: getEnv("PINNIPED_K8S_API_ENDPOINT"), + conciergeCABundle: base64.StdEncoding.EncodeToString([]byte(getEnv("PINNIPED_CA_BUNDLE"))), + } + if len(missing) > 0 { + return fmt.Errorf("failed to get credential: required environment variable(s) not set: %v", missing) + } + return runStaticLogin(cmd.OutOrStdout(), deps, flags) + } + return cmd +} diff --git a/cmd/pinniped/cmd/get.go b/cmd/pinniped/cmd/get.go new file mode 100644 index 000000000..27a027ae0 --- /dev/null +++ b/cmd/pinniped/cmd/get.go @@ -0,0 +1,20 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +//nolint: gochecknoglobals +var getCmd = &cobra.Command{ + Use: "get", + Short: "get", + SilenceUsage: true, // do not print usage message when commands fail +} + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(getCmd) +} diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go new file mode 100644 index 000000000..5bf8efe89 --- /dev/null +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -0,0 +1,364 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "os" + "strconv" + "strings" + "time" + + "github.com/coreos/go-oidc" + "github.com/spf13/cobra" + 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" + + _ "k8s.io/client-go/plugin/pkg/client/auth" // Adds handlers for various dynamic auth plugins in client-go + + conciergev1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" +) + +type kubeconfigDeps struct { + getPathToSelf func() (string, error) + getClientset func(clientcmd.ClientConfig) (conciergeclientset.Interface, error) +} + +func kubeconfigRealDeps() kubeconfigDeps { + return kubeconfigDeps{ + getPathToSelf: os.Executable, + getClientset: func(clientConfig clientcmd.ClientConfig) (conciergeclientset.Interface, error) { + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + return conciergeclientset.NewForConfig(restConfig) + }, + } +} + +//nolint: gochecknoinits +func init() { + getCmd.AddCommand(kubeconfigCommand(kubeconfigRealDeps())) +} + +type getKubeconfigOIDCParams struct { + issuer string + clientID string + listenPort uint16 + scopes []string + skipBrowser bool + sessionCachePath string + debugSessionCache bool + caBundlePaths []string + requestAudience string +} + +type getKubeconfigConciergeParams struct { + disabled bool + namespace string + authenticatorName string + authenticatorType string +} + +type getKubeconfigParams struct { + kubeconfigPath string + kubeconfigContextOverride string + staticToken string + staticTokenEnvName string + oidc getKubeconfigOIDCParams + concierge getKubeconfigConciergeParams +} + +func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { + var ( + cmd = cobra.Command{ + Args: cobra.NoArgs, + Use: "kubeconfig", + Short: "Generate a Pinniped-based kubeconfig for a cluster", + SilenceUsage: true, + } + flags getKubeconfigParams + ) + + f := cmd.Flags() + f.StringVar(&flags.staticToken, "static-token", "", "Instead of doing an OIDC-based login, specify a static token") + f.StringVar(&flags.staticTokenEnvName, "static-token-env", "", "Instead of doing an OIDC-based login, read a static token from the environment") + + f.BoolVar(&flags.concierge.disabled, "no-concierge", false, "Generate a configuration which does not use the concierge, but sends the credential to the cluster directly") + f.StringVar(&flags.concierge.namespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") + f.StringVar(&flags.concierge.authenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover)") + f.StringVar(&flags.concierge.authenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name (default: autodiscover)") + + f.StringVar(&flags.oidc.issuer, "oidc-issuer", "", "OpenID Connect issuer URL (default: autodiscover)") + f.StringVar(&flags.oidc.clientID, "oidc-client-id", "pinniped-cli", "OpenID Connect client ID (default: autodiscover)") + f.Uint16Var(&flags.oidc.listenPort, "oidc-listen-port", 0, "TCP port for localhost listener (authorization code flow only)") + f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped.sts.unrestricted"}, "OpenID Connect scopes to request during login") + f.BoolVar(&flags.oidc.skipBrowser, "oidc-skip-browser", false, "During OpenID Connect login, skip opening the browser (just print the URL)") + f.StringVar(&flags.oidc.sessionCachePath, "oidc-session-cache", "", "Path to OpenID Connect session cache file") + f.StringSliceVar(&flags.oidc.caBundlePaths, "oidc-ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)") + f.BoolVar(&flags.oidc.debugSessionCache, "oidc-debug-session-cache", false, "Print debug logs related to the OpenID Connect session cache") + f.StringVar(&flags.oidc.requestAudience, "oidc-request-audience", "", "Request a token with an alternate audience using RF8693 token exchange") + f.StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file") + f.StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)") + + mustMarkHidden(&cmd, "oidc-debug-session-cache") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { return runGetKubeconfig(cmd.OutOrStdout(), deps, flags) } + return &cmd +} + +func runGetKubeconfig(out io.Writer, deps kubeconfigDeps, flags getKubeconfigParams) error { + execConfig := clientcmdapi.ExecConfig{ + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + Args: []string{}, + Env: []clientcmdapi.ExecEnvVar{}, + } + + var err error + execConfig.Command, err = deps.getPathToSelf() + if err != nil { + return fmt.Errorf("could not determine the Pinniped executable path: %w", err) + } + + oidcCABundle, err := loadCABundlePaths(flags.oidc.caBundlePaths) + if err != nil { + return fmt.Errorf("could not read --oidc-ca-bundle: %w", err) + } + + clientConfig := newClientConfig(flags.kubeconfigPath, flags.kubeconfigContextOverride) + currentKubeConfig, err := clientConfig.RawConfig() + if err != nil { + return fmt.Errorf("could not load --kubeconfig: %w", err) + } + cluster, err := copyCurrentClusterFromExistingKubeConfig(currentKubeConfig, flags.kubeconfigContextOverride) + if err != nil { + return fmt.Errorf("could not load --kubeconfig/--kubeconfig-context: %w", err) + } + clientset, err := deps.getClientset(clientConfig) + if err != nil { + return fmt.Errorf("could not configure Kubernetes client: %w", err) + } + + if !flags.concierge.disabled { + authenticator, err := lookupAuthenticator( + clientset, + flags.concierge.namespace, + flags.concierge.authenticatorType, + flags.concierge.authenticatorName, + ) + if err != nil { + return err + } + if err := configureConcierge(authenticator, &flags, cluster, &oidcCABundle, &execConfig); err != nil { + return err + } + } + + // If one of the --static-* flags was passed, output a config that runs `pinniped login static`. + if flags.staticToken != "" || flags.staticTokenEnvName != "" { + if flags.staticToken != "" && flags.staticTokenEnvName != "" { + return fmt.Errorf("only one of --static-token and --static-token-env can be specified") + } + execConfig.Args = append([]string{"login", "static"}, execConfig.Args...) + if flags.staticToken != "" { + execConfig.Args = append(execConfig.Args, "--token="+flags.staticToken) + } + if flags.staticTokenEnvName != "" { + execConfig.Args = append(execConfig.Args, "--token-env="+flags.staticTokenEnvName) + } + return writeConfigAsYAML(out, newExecKubeconfig(cluster, &execConfig)) + } + + // Otherwise continue to parse the OIDC-related flags and output a config that runs `pinniped login oidc`. + execConfig.Args = append([]string{"login", "oidc"}, execConfig.Args...) + if flags.oidc.issuer == "" { + return fmt.Errorf("could not autodiscover --oidc-issuer, and none was provided") + } + execConfig.Args = append(execConfig.Args, + "--issuer="+flags.oidc.issuer, + "--client-id="+flags.oidc.clientID, + "--scopes="+strings.Join(flags.oidc.scopes, ","), + ) + if flags.oidc.skipBrowser { + execConfig.Args = append(execConfig.Args, "--skip-browser") + } + if flags.oidc.listenPort != 0 { + execConfig.Args = append(execConfig.Args, "--listen-port="+strconv.Itoa(int(flags.oidc.listenPort))) + } + if oidcCABundle != "" { + execConfig.Args = append(execConfig.Args, "--ca-bundle-data="+base64.StdEncoding.EncodeToString([]byte(oidcCABundle))) + } + if flags.oidc.sessionCachePath != "" { + execConfig.Args = append(execConfig.Args, "--session-cache="+flags.oidc.sessionCachePath) + } + if flags.oidc.debugSessionCache { + execConfig.Args = append(execConfig.Args, "--debug-session-cache") + } + if flags.oidc.requestAudience != "" { + execConfig.Args = append(execConfig.Args, "--request-audience="+flags.oidc.requestAudience) + } + return writeConfigAsYAML(out, newExecKubeconfig(cluster, &execConfig)) +} + +func configureConcierge(authenticator metav1.Object, flags *getKubeconfigParams, v1Cluster *clientcmdapi.Cluster, oidcCABundle *string, execConfig *clientcmdapi.ExecConfig) error { + switch auth := authenticator.(type) { + case *conciergev1alpha1.WebhookAuthenticator: + // If the --concierge-authenticator-type/--concierge-authenticator-name flags were not set explicitly, set + // them to point at the discovered WebhookAuthenticator. + if flags.concierge.authenticatorType == "" && flags.concierge.authenticatorName == "" { + flags.concierge.authenticatorType = "webhook" + flags.concierge.authenticatorName = auth.Name + } + case *conciergev1alpha1.JWTAuthenticator: + // If the --concierge-authenticator-type/--concierge-authenticator-name flags were not set explicitly, set + // them to point at the discovered JWTAuthenticator. + if flags.concierge.authenticatorType == "" && flags.concierge.authenticatorName == "" { + flags.concierge.authenticatorType = "jwt" + flags.concierge.authenticatorName = auth.Name + } + + // If the --oidc-issuer flag was not set explicitly, default it to the spec.issuer field of the JWTAuthenticator. + if flags.oidc.issuer == "" { + flags.oidc.issuer = auth.Spec.Issuer + } + + // If the --oidc-request-audience flag was not set explicitly, default it to the spec.audience field of the JWTAuthenticator. + if flags.oidc.requestAudience == "" { + flags.oidc.requestAudience = auth.Spec.Audience + } + + // If the --oidc-ca-bundle flags was not set explicitly, default it to the + // spec.tls.certificateAuthorityData field of the JWTAuthenticator. + if *oidcCABundle == "" && auth.Spec.TLS != nil && auth.Spec.TLS.CertificateAuthorityData != "" { + decoded, err := base64.StdEncoding.DecodeString(auth.Spec.TLS.CertificateAuthorityData) + if err != nil { + return fmt.Errorf("tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator %s/%s has invalid spec.tls.certificateAuthorityData: %w", auth.Namespace, auth.Name, err) + } + *oidcCABundle = string(decoded) + } + } + + // Append the flags to configure the Concierge credential exchange at runtime. + execConfig.Args = append(execConfig.Args, + "--enable-concierge", + "--concierge-namespace="+flags.concierge.namespace, + "--concierge-authenticator-name="+flags.concierge.authenticatorName, + "--concierge-authenticator-type="+flags.concierge.authenticatorType, + "--concierge-endpoint="+v1Cluster.Server, + "--concierge-ca-bundle-data="+base64.StdEncoding.EncodeToString(v1Cluster.CertificateAuthorityData), + ) + return nil +} + +func loadCABundlePaths(paths []string) (string, error) { + if len(paths) == 0 { + return "", nil + } + blobs := make([][]byte, 0, len(paths)) + for _, p := range paths { + pem, err := ioutil.ReadFile(p) + if err != nil { + return "", err + } + blobs = append(blobs, pem) + } + return string(bytes.Join(blobs, []byte("\n"))), nil +} + +func newExecKubeconfig(cluster *clientcmdapi.Cluster, execConfig *clientcmdapi.ExecConfig) clientcmdapi.Config { + const name = "pinniped" + return clientcmdapi.Config{ + Kind: "Config", + APIVersion: clientcmdapi.SchemeGroupVersion.Version, + Clusters: map[string]*clientcmdapi.Cluster{name: cluster}, + AuthInfos: map[string]*clientcmdapi.AuthInfo{name: {Exec: execConfig}}, + Contexts: map[string]*clientcmdapi.Context{name: {Cluster: name, AuthInfo: name}}, + CurrentContext: name, + } +} + +func lookupAuthenticator(clientset conciergeclientset.Interface, namespace, authType, authName string) (metav1.Object, error) { + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*20) + defer cancelFunc() + + // If one was specified, look it up or error. + if authName != "" && authType != "" { + switch strings.ToLower(authType) { + case "webhook": + return clientset.AuthenticationV1alpha1().WebhookAuthenticators(namespace).Get(ctx, authName, metav1.GetOptions{}) + case "jwt": + return clientset.AuthenticationV1alpha1().JWTAuthenticators(namespace).Get(ctx, authName, metav1.GetOptions{}) + default: + return nil, fmt.Errorf(`invalid authenticator type %q, supported values are "webhook" and "jwt"`, authType) + } + } + + // Otherwise list all the available authenticators and hope there's just a single one. + + jwtAuths, err := clientset.AuthenticationV1alpha1().JWTAuthenticators(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list JWTAuthenticator objects for autodiscovery: %w", err) + } + webhooks, err := clientset.AuthenticationV1alpha1().WebhookAuthenticators(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list WebhookAuthenticator objects for autodiscovery: %w", err) + } + + results := make([]metav1.Object, 0, len(jwtAuths.Items)+len(webhooks.Items)) + for i := range jwtAuths.Items { + results = append(results, &jwtAuths.Items[i]) + } + for i := range webhooks.Items { + results = append(results, &webhooks.Items[i]) + } + if len(results) == 0 { + return nil, fmt.Errorf("no authenticators were found in namespace %q (try setting --concierge-namespace)", namespace) + } + if len(results) > 1 { + return nil, fmt.Errorf("multiple authenticators were found in namespace %q, so the --concierge-authenticator-type/--concierge-authenticator-name flags must be specified", namespace) + } + return results[0], nil +} + +func newClientConfig(kubeconfigPathOverride string, currentContextName string) clientcmd.ClientConfig { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeconfigPathOverride + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{ + CurrentContext: currentContextName, + }) + return clientConfig +} + +func writeConfigAsYAML(out io.Writer, config clientcmdapi.Config) error { + output, err := clientcmd.Write(config) + if err != nil { + return err + } + _, err = out.Write(output) + if err != nil { + return fmt.Errorf("could not write output: %w", err) + } + return nil +} + +func copyCurrentClusterFromExistingKubeConfig(currentKubeConfig clientcmdapi.Config, currentContextNameOverride string) (*clientcmdapi.Cluster, error) { + contextName := currentKubeConfig.CurrentContext + if currentContextNameOverride != "" { + contextName = currentContextNameOverride + } + context := currentKubeConfig.Contexts[contextName] + if context == nil { + return nil, fmt.Errorf("no such context %q", contextName) + } + return currentKubeConfig.Clusters[context.Cluster], nil +} diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go new file mode 100644 index 000000000..739df951e --- /dev/null +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -0,0 +1,524 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "io/ioutil" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kubetesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/clientcmd" + + conciergev1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned" + fakeconciergeclientset "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned/fake" + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" +) + +func TestGetKubeconfig(t *testing.T) { + testCA, err := certauthority.New(pkix.Name{CommonName: "Test CA"}, 1*time.Hour) + require.NoError(t, err) + tmpdir := testutil.TempDir(t) + testCABundlePath := filepath.Join(tmpdir, "testca.pem") + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCA.Bundle(), 0600)) + + tests := []struct { + name string + args []string + env map[string]string + getPathToSelfErr error + getClientsetErr error + conciergeObjects []runtime.Object + conciergeReactions []kubetesting.Reactor + wantError bool + wantStdout string + wantStderr string + wantOptionsCount int + }{ + { + name: "help flag passed", + args: []string{"--help"}, + wantStdout: here.Doc(` + Generate a Pinniped-based kubeconfig for a cluster + + Usage: + kubeconfig [flags] + + Flags: + --concierge-authenticator-name string Concierge authenticator name (default: autodiscover) + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover) + --concierge-namespace string Namespace in which the concierge was installed (default "pinniped-concierge") + -h, --help help for kubeconfig + --kubeconfig string Path to kubeconfig file + --kubeconfig-context string Kubeconfig context name (default: current active context) + --no-concierge Generate a configuration which does not use the concierge, but sends the credential to the cluster directly + --oidc-ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated) + --oidc-client-id string OpenID Connect client ID (default: autodiscover) (default "pinniped-cli") + --oidc-issuer string OpenID Connect issuer URL (default: autodiscover) + --oidc-listen-port uint16 TCP port for localhost listener (authorization code flow only) + --oidc-request-audience string Request a token with an alternate audience using RF8693 token exchange + --oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped.sts.unrestricted]) + --oidc-session-cache string Path to OpenID Connect session cache file + --oidc-skip-browser During OpenID Connect login, skip opening the browser (just print the URL) + --static-token string Instead of doing an OIDC-based login, specify a static token + --static-token-env string Instead of doing an OIDC-based login, read a static token from the environment + `), + }, + { + name: "fail to get self-path", + args: []string{}, + getPathToSelfErr: fmt.Errorf("some OS error"), + wantError: true, + wantStdout: here.Doc(` + Error: could not determine the Pinniped executable path: some OS error + `), + }, + { + name: "invalid CA bundle paths", + args: []string{ + "--oidc-ca-bundle", "./does/not/exist", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not read --oidc-ca-bundle: open ./does/not/exist: no such file or directory + `), + }, + { + name: "invalid kubeconfig path", + args: []string{ + "--kubeconfig", "./does/not/exist", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not load --kubeconfig: stat ./does/not/exist: no such file or directory + `), + }, + { + name: "invalid kubeconfig context", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--kubeconfig-context", "invalid", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not load --kubeconfig/--kubeconfig-context: no such context "invalid" + `), + }, + { + name: "clientset creation failure", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + getClientsetErr: fmt.Errorf("some kube error"), + wantError: true, + wantStdout: here.Doc(` + Error: could not configure Kubernetes client: some kube error + `), + }, + { + name: "webhook authenticator not found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + }, + wantError: true, + wantStdout: here.Doc(` + Error: webhookauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found + `), + }, + { + name: "JWT authenticator not found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", "test-authenticator", + }, + wantError: true, + wantStdout: here.Doc(` + Error: jwtauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found + `), + }, + { + name: "invalid authenticator type", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "invalid", + "--concierge-authenticator-name", "test-authenticator", + }, + wantError: true, + wantStdout: here.Doc(` + Error: invalid authenticator type "invalid", supported values are "webhook" and "jwt" + `), + }, + { + name: "fail to autodetect authenticator, listing jwtauthenticators fails", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + conciergeReactions: []kubetesting.Reactor{ + &kubetesting.SimpleReactor{ + Verb: "*", + Resource: "jwtauthenticators", + Reaction: func(kubetesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("some list error") + }, + }, + }, + wantError: true, + wantStdout: here.Doc(` + Error: failed to list JWTAuthenticator objects for autodiscovery: some list error + `), + }, + { + name: "fail to autodetect authenticator, listing webhookauthenticators fails", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + conciergeReactions: []kubetesting.Reactor{ + &kubetesting.SimpleReactor{ + Verb: "*", + Resource: "webhookauthenticators", + Reaction: func(kubetesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("some list error") + }, + }, + }, + wantError: true, + wantStdout: here.Doc(` + Error: failed to list WebhookAuthenticator objects for autodiscovery: some list error + `), + }, + { + name: "fail to autodetect authenticator, none found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + wantError: true, + wantStdout: here.Doc(` + Error: no authenticators were found in namespace "pinniped-concierge" (try setting --concierge-namespace) + `), + }, + { + name: "fail to autodetect authenticator, multiple found", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-1", Namespace: "test-namespace"}}, + &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-2", Namespace: "test-namespace"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-3", Namespace: "test-namespace"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-4", Namespace: "test-namespace"}}, + }, + wantError: true, + wantStdout: here.Doc(` + Error: multiple authenticators were found in namespace "test-namespace", so the --concierge-authenticator-type/--concierge-authenticator-name flags must be specified + `), + }, + { + name: "autodetect webhook authenticator, missing --oidc-issuer", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not autodiscover --oidc-issuer, and none was provided + `), + }, + { + name: "autodetect JWT authenticator, invalid TLS bundle", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + TLS: &conciergev1alpha1.TLSSpec{ + CertificateAuthorityData: "invalid-base64", + }, + }, + }, + }, + wantError: true, + wantStdout: here.Doc(` + Error: tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator test-namespace/test-authenticator has invalid spec.tls.certificateAuthorityData: illegal base64 data at input byte 7 + `), + }, + { + name: "invalid static token flags", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + "--static-token", "test-token", + "--static-token-env", "TEST_TOKEN", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantError: true, + wantStdout: here.Doc(` + Error: only one of --static-token and --static-token-env can be specified + `), + }, + { + name: "valid static token", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + "--static-token", "test-token", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantStdout: here.Doc(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - static + - --enable-concierge + - --concierge-namespace=test-namespace + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --token=test-token + command: '.../path/to/pinniped' + env: [] + `), + }, + { + name: "valid static token from env var", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-namespace", "test-namespace", + "--static-token-env", "TEST_TOKEN", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "test-namespace"}}, + }, + wantStdout: here.Doc(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - static + - --enable-concierge + - --concierge-namespace=test-namespace + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --token-env=TEST_TOKEN + command: '.../path/to/pinniped' + env: [] + `), + }, + { + name: "autodetect JWT authenticator", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "pinniped-concierge"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://example.com/issuer", + Audience: "test-audience", + TLS: &conciergev1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(testCA.Bundle()), + }, + }, + }, + }, + wantStdout: here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-namespace=pinniped-concierge + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=https://example.com/issuer + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped.sts.unrestricted + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + `, base64.StdEncoding.EncodeToString(testCA.Bundle())), + }, + { + name: "autodetect nothing, set a bunch of options", + args: []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + "--oidc-issuer", "https://example.com/issuer", + "--oidc-skip-browser", + "--oidc-listen-port", "1234", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", "/path/to/cache/dir/sessions.yaml", + "--oidc-debug-session-cache", + "--oidc-request-audience", "test-audience", + }, + conciergeObjects: []runtime.Object{ + &conciergev1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator", Namespace: "pinniped-concierge"}, + }, + }, + wantStdout: here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: pinniped + contexts: + - context: + cluster: pinniped + user: pinniped + name: pinniped + current-context: pinniped + kind: Config + preferences: {} + users: + - name: pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-namespace=pinniped-concierge + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=https://example.com/issuer + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped.sts.unrestricted + - --skip-browser + - --listen-port=1234 + - --ca-bundle-data=%s + - --session-cache=/path/to/cache/dir/sessions.yaml + - --debug-session-cache + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + `, base64.StdEncoding.EncodeToString(testCA.Bundle())), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cmd := kubeconfigCommand(kubeconfigDeps{ + getPathToSelf: func() (string, error) { + if tt.getPathToSelfErr != nil { + return "", tt.getPathToSelfErr + } + return ".../path/to/pinniped", nil + }, + getClientset: func(clientConfig clientcmd.ClientConfig) (conciergeclientset.Interface, error) { + if tt.getClientsetErr != nil { + return nil, tt.getClientsetErr + } + fake := fakeconciergeclientset.NewSimpleClientset(tt.conciergeObjects...) + if len(tt.conciergeReactions) > 0 { + fake.ReactionChain = tt.conciergeReactions + } + return fake, nil + }, + }) + require.NotNil(t, cmd) + + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + }) + } +} diff --git a/cmd/pinniped/cmd/login.go b/cmd/pinniped/cmd/login.go index faa8b1374..e27442ee2 100644 --- a/cmd/pinniped/cmd/login.go +++ b/cmd/pinniped/cmd/login.go @@ -12,7 +12,8 @@ var loginCmd = &cobra.Command{ Use: "login", Short: "login", Long: "Login to a Pinniped server", - SilenceUsage: true, // do not print usage message when commands fail + SilenceUsage: true, // Do not print usage message when commands fail. + Hidden: true, // These commands are not really meant to be used directly by users, so it's confusing to have them discoverable. } //nolint: gochecknoinits diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index ee74238af..e3078458a 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -4,21 +4,25 @@ package cmd import ( + "context" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "fmt" "io/ioutil" "net/http" "os" "path/filepath" + "time" "github.com/coreos/go-oidc" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "k8s.io/klog/v2/klogr" + "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -26,114 +30,193 @@ import ( //nolint: gochecknoinits func init() { - loginCmd.AddCommand(oidcLoginCommand(oidcclient.Login)) + loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps())) } -func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error)) *cobra.Command { +type oidcLoginCommandDeps struct { + login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error) + exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error) +} + +func oidcLoginCommandRealDeps() oidcLoginCommandDeps { + return oidcLoginCommandDeps{ + login: oidcclient.Login, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + return client.ExchangeToken(ctx, token) + }, + } +} + +type oidcLoginFlags struct { + issuer string + clientID string + listenPort uint16 + scopes []string + skipBrowser bool + sessionCachePath string + caBundlePaths []string + caBundleData []string + debugSessionCache bool + requestAudience string + conciergeEnabled bool + conciergeNamespace string + conciergeAuthenticatorType string + conciergeAuthenticatorName string + conciergeEndpoint string + conciergeCABundle string +} + +func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { var ( cmd = cobra.Command{ Args: cobra.NoArgs, - Use: "oidc --issuer ISSUER --client-id CLIENT_ID", + Use: "oidc --issuer ISSUER", Short: "Login using an OpenID Connect provider", SilenceUsage: true, } - issuer string - clientID string - listenPort uint16 - scopes []string - skipBrowser bool - sessionCachePath string - caBundlePaths []string - debugSessionCache bool - requestAudience string + flags oidcLoginFlags ) - cmd.Flags().StringVar(&issuer, "issuer", "", "OpenID Connect issuer URL.") - cmd.Flags().StringVar(&clientID, "client-id", "pinniped-cli", "OpenID Connect client ID.") - cmd.Flags().Uint16Var(&listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only).") - cmd.Flags().StringSliceVar(&scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped.sts.unrestricted"}, "OIDC scopes to request during login.") - cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).") - cmd.Flags().StringVar(&sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file.") - cmd.Flags().StringSliceVar(&caBundlePaths, "ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated).") - cmd.Flags().BoolVar(&debugSessionCache, "debug-session-cache", false, "Print debug logs related to the session cache.") - cmd.Flags().StringVar(&requestAudience, "request-audience", "", "Request a token with an alternate audience using RF8693 token exchange.") + cmd.Flags().StringVar(&flags.issuer, "issuer", "", "OpenID Connect issuer URL") + cmd.Flags().StringVar(&flags.clientID, "client-id", "pinniped-cli", "OpenID Connect client ID") + cmd.Flags().Uint16Var(&flags.listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only)") + cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped.sts.unrestricted"}, "OIDC scopes to request during login") + cmd.Flags().BoolVar(&flags.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL)") + cmd.Flags().StringVar(&flags.sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file") + cmd.Flags().StringSliceVar(&flags.caBundlePaths, "ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)") + cmd.Flags().StringSliceVar(&flags.caBundleData, "ca-bundle-data", nil, "Base64 endcoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated)") + cmd.Flags().BoolVar(&flags.debugSessionCache, "debug-session-cache", false, "Print debug logs related to the session cache") + cmd.Flags().StringVar(&flags.requestAudience, "request-audience", "", "Request a token with an alternate audience using RF8693 token exchange") + cmd.Flags().BoolVar(&flags.conciergeEnabled, "enable-concierge", false, "Exchange the OIDC ID token with the Pinniped concierge during login") + cmd.Flags().StringVar(&flags.conciergeNamespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt')") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name") + cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Pinniped concierge endpoint") + cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the concierge") + mustMarkHidden(&cmd, "debug-session-cache") mustMarkRequired(&cmd, "issuer") + cmd.RunE = func(cmd *cobra.Command, args []string) error { return runOIDCLogin(cmd, deps, flags) } + return &cmd +} - cmd.RunE = func(cmd *cobra.Command, args []string) error { - // Initialize the session cache. - var sessionOptions []filesession.Option +func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLoginFlags) error { + // Initialize the session cache. + var sessionOptions []filesession.Option - // If the hidden --debug-session-cache option is passed, log all the errors from the session cache with klog. - if debugSessionCache { - logger := klogr.New().WithName("session") - sessionOptions = append(sessionOptions, filesession.WithErrorReporter(func(err error) { - logger.Error(err, "error during session cache operation") - })) + // If the hidden --debug-session-cache option is passed, log all the errors from the session cache with klog. + if flags.debugSessionCache { + logger := klogr.New().WithName("session") + sessionOptions = append(sessionOptions, filesession.WithErrorReporter(func(err error) { + logger.Error(err, "error during session cache operation") + })) + } + sessionCache := filesession.New(flags.sessionCachePath, sessionOptions...) + + // Initialize the login handler. + opts := []oidcclient.Option{ + oidcclient.WithContext(cmd.Context()), + oidcclient.WithScopes(flags.scopes), + oidcclient.WithSessionCache(sessionCache), + } + + if flags.listenPort != 0 { + opts = append(opts, oidcclient.WithListenPort(flags.listenPort)) + } + + if flags.requestAudience != "" { + opts = append(opts, oidcclient.WithRequestAudience(flags.requestAudience)) + } + + var concierge *conciergeclient.Client + if flags.conciergeEnabled { + var err error + concierge, err = conciergeclient.New( + conciergeclient.WithNamespace(flags.conciergeNamespace), + conciergeclient.WithEndpoint(flags.conciergeEndpoint), + conciergeclient.WithBase64CABundle(flags.conciergeCABundle), + conciergeclient.WithAuthenticator(flags.conciergeAuthenticatorType, flags.conciergeAuthenticatorName), + ) + if err != nil { + return fmt.Errorf("invalid concierge parameters: %w", err) } - sessionCache := filesession.New(sessionCachePath, sessionOptions...) + } - // Initialize the login handler. - opts := []oidcclient.Option{ - oidcclient.WithContext(cmd.Context()), - oidcclient.WithScopes(scopes), - oidcclient.WithSessionCache(sessionCache), - } + // --skip-browser replaces the default "browser open" function with one that prints to stderr. + if flags.skipBrowser { + opts = append(opts, oidcclient.WithBrowserOpen(func(url string) error { + cmd.PrintErr("Please log in: ", url, "\n") + return nil + })) + } - if listenPort != 0 { - opts = append(opts, oidcclient.WithListenPort(listenPort)) - } - - if requestAudience != "" { - opts = append(opts, oidcclient.WithRequestAudience(requestAudience)) - } - - // --skip-browser replaces the default "browser open" function with one that prints to stderr. - if skipBrowser { - opts = append(opts, oidcclient.WithBrowserOpen(func(url string) error { - cmd.PrintErr("Please log in: ", url, "\n") - return nil - })) - } - - if len(caBundlePaths) > 0 { - pool := x509.NewCertPool() - for _, p := range caBundlePaths { - pem, err := ioutil.ReadFile(p) - if err != nil { - return fmt.Errorf("could not read --ca-bundle: %w", err) - } - pool.AppendCertsFromPEM(pem) - } - tlsConfig := tls.Config{ - RootCAs: pool, - MinVersion: tls.VersionTLS12, - } - opts = append(opts, oidcclient.WithClient(&http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tlsConfig, - }, - })) - } - - tok, err := loginFunc(issuer, clientID, opts...) + if len(flags.caBundlePaths) > 0 || len(flags.caBundleData) > 0 { + client, err := makeClient(flags.caBundlePaths, flags.caBundleData) if err != nil { return err } - - // Convert the token out to Kubernetes ExecCredential JSON format for output. - return json.NewEncoder(cmd.OutOrStdout()).Encode(&clientauthenticationv1beta1.ExecCredential{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExecCredential", - APIVersion: "client.authentication.k8s.io/v1beta1", - }, - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - ExpirationTimestamp: &tok.IDToken.Expiry, - Token: tok.IDToken.Token, - }, - }) + opts = append(opts, oidcclient.WithClient(client)) } - return &cmd + + // Do the basic login to get an OIDC token. + token, err := deps.login(flags.issuer, flags.clientID, opts...) + if err != nil { + return fmt.Errorf("could not complete Pinniped login: %w", err) + } + cred := tokenCredential(token) + + // If the concierge was configured, exchange the credential for a separate short-lived, cluster-specific credential. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if concierge != nil { + cred, err = deps.exchangeToken(ctx, concierge, token.IDToken.Token) + if err != nil { + return fmt.Errorf("could not complete concierge credential exchange: %w", err) + } + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) +} +func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, error) { + pool := x509.NewCertPool() + for _, p := range caBundlePaths { + pem, err := ioutil.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("could not read --ca-bundle: %w", err) + } + pool.AppendCertsFromPEM(pem) + } + for _, d := range caBundleData { + pem, err := base64.StdEncoding.DecodeString(d) + if err != nil { + return nil, fmt.Errorf("could not read --ca-bundle-data: %w", err) + } + pool.AppendCertsFromPEM(pem) + } + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS12, + }, + }, + }, nil +} + +func tokenCredential(token *oidctypes.Token) *clientauthv1beta1.ExecCredential { + cred := clientauthv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthv1beta1.ExecCredentialStatus{ + Token: token.IDToken.Token, + }, + } + if !token.IDToken.Expiry.IsZero() { + cred.Status.ExpirationTimestamp = &token.IDToken.Expiry + } + return &cred } // mustGetConfigDir returns a directory that follows the XDG base directory convention: diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index f42a22240..df4f8f9db 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -5,13 +5,23 @@ package cmd import ( "bytes" + "context" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "io/ioutil" + "path/filepath" "testing" "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/oidctypes" ) @@ -19,16 +29,22 @@ import ( func TestLoginOIDCCommand(t *testing.T) { cfgDir := mustGetConfigDir() + testCA, err := certauthority.New(pkix.Name{CommonName: "Test CA"}, 1*time.Hour) + require.NoError(t, err) + tmpdir := testutil.TempDir(t) + testCABundlePath := filepath.Join(tmpdir, "testca.pem") + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCA.Bundle(), 0600)) + time1 := time.Date(3020, 10, 12, 13, 14, 15, 16, time.UTC) tests := []struct { name string args []string + loginErr error + conciergeErr error wantError bool wantStdout string wantStderr string - wantIssuer string - wantClientID string wantOptionsCount int }{ { @@ -38,18 +54,25 @@ func TestLoginOIDCCommand(t *testing.T) { Login using an OpenID Connect provider Usage: - oidc --issuer ISSUER --client-id CLIENT_ID [flags] + oidc --issuer ISSUER [flags] Flags: - --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated). - --client-id string OpenID Connect client ID. (default "pinniped-cli") - -h, --help help for oidc - --issuer string OpenID Connect issuer URL. - --listen-port uint16 TCP port for localhost listener (authorization code flow only). - --request-audience string Request a token with an alternate audience using RF8693 token exchange. - --scopes strings OIDC scopes to request during login. (default [offline_access,openid,pinniped.sts.unrestricted]) - --session-cache string Path to session cache file. (default "` + cfgDir + `/sessions.yaml") - --skip-browser Skip opening the browser (just print the URL). + --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated) + --ca-bundle-data strings Base64 endcoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated) + --client-id string OpenID Connect client ID (default "pinniped-cli") + --concierge-authenticator-name string Concierge authenticator name + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') + --concierge-ca-bundle-data string CA bundle to use when connecting to the concierge + --concierge-endpoint string API base for the Pinniped concierge endpoint + --concierge-namespace string Namespace in which the concierge was installed (default "pinniped-concierge") + --enable-concierge Exchange the OIDC ID token with the Pinniped concierge during login + -h, --help help for oidc + --issuer string OpenID Connect issuer URL + --listen-port uint16 TCP port for localhost listener (authorization code flow only) + --request-audience string Request a token with an alternate audience using RF8693 token exchange + --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped.sts.unrestricted]) + --session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml") + --skip-browser Skip opening the browser (just print the URL) `), }, { @@ -60,14 +83,78 @@ func TestLoginOIDCCommand(t *testing.T) { Error: required flag(s) "issuer" not set `), }, + { + name: "missing concierge flags", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--enable-concierge", + }, + wantError: true, + wantStdout: here.Doc(` + Error: invalid concierge parameters: endpoint must not be empty + `), + }, + { + name: "invalid CA bundle path", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--ca-bundle", "./does/not/exist", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not read --ca-bundle: open ./does/not/exist: no such file or directory + `), + }, + { + name: "invalid CA bundle data", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--ca-bundle-data", "invalid-base64", + }, + wantError: true, + wantStdout: here.Doc(` + Error: could not read --ca-bundle-data: illegal base64 data at input byte 7 + `), + }, + { + name: "login error", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + }, + loginErr: fmt.Errorf("some login error"), + wantOptionsCount: 3, + wantError: true, + wantStdout: here.Doc(` + Error: could not complete Pinniped login: some login error + `), + }, + { + name: "concierge token exchange error", + args: []string{ + "--client-id", "test-client-id", + "--issuer", "test-issuer", + "--enable-concierge", + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", "test-authenticator", + "--concierge-endpoint", "https://127.0.0.1:1234/", + }, + conciergeErr: fmt.Errorf("some concierge error"), + wantOptionsCount: 3, + wantError: true, + wantStdout: here.Doc(` + Error: could not complete concierge credential exchange: some concierge error + `), + }, { name: "success with minimal options", args: []string{ "--client-id", "test-client-id", "--issuer", "test-issuer", }, - wantIssuer: "test-issuer", - wantClientID: "test-client-id", wantOptionsCount: 3, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, @@ -79,31 +166,56 @@ func TestLoginOIDCCommand(t *testing.T) { "--skip-browser", "--listen-port", "1234", "--debug-session-cache", + "--request-audience", "cluster-1234", + "--ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()), + "--ca-bundle", testCABundlePath, + "--enable-concierge", + "--concierge-namespace", "test-namespace", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + "--concierge-endpoint", "https://127.0.0.1:1234/", + "--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()), }, - wantIssuer: "test-issuer", - wantClientID: "test-client-id", - wantOptionsCount: 5, - wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", + wantOptionsCount: 7, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { var ( - gotIssuer string - gotClientID string - gotOptions []oidcclient.Option + gotOptions []oidcclient.Option ) - cmd := oidcLoginCommand(func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error) { - gotIssuer = issuer - gotClientID = clientID - gotOptions = opts - return &oidctypes.Token{ - IDToken: &oidctypes.IDToken{ - Token: "test-id-token", - Expiry: metav1.NewTime(time1), - }, - }, nil + cmd := oidcLoginCommand(oidcLoginCommandDeps{ + login: func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error) { + require.Equal(t, "test-issuer", issuer) + require.Equal(t, "test-client-id", clientID) + gotOptions = opts + if tt.loginErr != nil { + return nil, tt.loginErr + } + return &oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Token: "test-id-token", + Expiry: metav1.NewTime(time1), + }, + }, nil + }, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + require.Equal(t, token, "test-id-token") + if tt.conciergeErr != nil { + return nil, tt.conciergeErr + } + return &clientauthv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthv1beta1.ExecCredentialStatus{ + Token: "exchanged-token", + }, + }, nil + }, }) require.NotNil(t, cmd) @@ -119,8 +231,6 @@ func TestLoginOIDCCommand(t *testing.T) { } require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") - require.Equal(t, tt.wantIssuer, gotIssuer, "unexpected issuer") - require.Equal(t, tt.wantClientID, gotClientID, "unexpected client ID") require.Len(t, gotOptions, tt.wantOptionsCount) }) } diff --git a/cmd/pinniped/cmd/login_static.go b/cmd/pinniped/cmd/login_static.go new file mode 100644 index 000000000..a051e4faf --- /dev/null +++ b/cmd/pinniped/cmd/login_static.go @@ -0,0 +1,120 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + "go.pinniped.dev/pkg/conciergeclient" + "go.pinniped.dev/pkg/oidcclient/oidctypes" +) + +//nolint: gochecknoinits +func init() { + loginCmd.AddCommand(staticLoginCommand(staticLoginRealDeps())) +} + +type staticLoginDeps struct { + lookupEnv func(string) (string, bool) + exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error) +} + +func staticLoginRealDeps() staticLoginDeps { + return staticLoginDeps{ + lookupEnv: os.LookupEnv, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + return client.ExchangeToken(ctx, token) + }, + } +} + +type staticLoginParams struct { + staticToken string + staticTokenEnvName string + conciergeEnabled bool + conciergeNamespace string + conciergeAuthenticatorType string + conciergeAuthenticatorName string + conciergeEndpoint string + conciergeCABundle string +} + +func staticLoginCommand(deps staticLoginDeps) *cobra.Command { + var ( + cmd = cobra.Command{ + Args: cobra.NoArgs, + Use: "static [--token TOKEN] [--token-env TOKEN_NAME]", + Short: "Login using a static token", + SilenceUsage: true, + } + flags staticLoginParams + ) + cmd.Flags().StringVar(&flags.staticToken, "token", "", "Static token to present during login") + cmd.Flags().StringVar(&flags.staticTokenEnvName, "token-env", "", "Environment variable containing a static token") + cmd.Flags().BoolVar(&flags.conciergeEnabled, "enable-concierge", false, "Exchange the token with the Pinniped concierge during login") + cmd.Flags().StringVar(&flags.conciergeNamespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt')") + cmd.Flags().StringVar(&flags.conciergeAuthenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name") + cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Pinniped concierge endpoint") + cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the concierge") + cmd.RunE = func(cmd *cobra.Command, args []string) error { return runStaticLogin(cmd.OutOrStdout(), deps, flags) } + return &cmd +} + +func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams) error { + if flags.staticToken == "" && flags.staticTokenEnvName == "" { + return fmt.Errorf("one of --token or --token-env must be set") + } + + var concierge *conciergeclient.Client + if flags.conciergeEnabled { + var err error + concierge, err = conciergeclient.New( + conciergeclient.WithNamespace(flags.conciergeNamespace), + conciergeclient.WithEndpoint(flags.conciergeEndpoint), + conciergeclient.WithBase64CABundle(flags.conciergeCABundle), + conciergeclient.WithAuthenticator(flags.conciergeAuthenticatorType, flags.conciergeAuthenticatorName), + ) + if err != nil { + return fmt.Errorf("invalid concierge parameters: %w", err) + } + } + + var token string + if flags.staticToken != "" { + token = flags.staticToken + } + if flags.staticTokenEnvName != "" { + var ok bool + token, ok = deps.lookupEnv(flags.staticTokenEnvName) + if !ok { + return fmt.Errorf("--token-env variable %q is not set", flags.staticTokenEnvName) + } + if token == "" { + return fmt.Errorf("--token-env variable %q is empty", flags.staticTokenEnvName) + } + } + cred := tokenCredential(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: token}}) + + // Exchange that token with the concierge, if configured. + if concierge != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var err error + cred, err = deps.exchangeToken(ctx, concierge, token) + if err != nil { + return fmt.Errorf("could not complete concierge credential exchange: %w", err) + } + } + return json.NewEncoder(out).Encode(cred) +} diff --git a/cmd/pinniped/cmd/login_static_test.go b/cmd/pinniped/cmd/login_static_test.go new file mode 100644 index 000000000..2b98a76b3 --- /dev/null +++ b/cmd/pinniped/cmd/login_static_test.go @@ -0,0 +1,180 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "crypto/x509/pkix" + "fmt" + "io/ioutil" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/pkg/conciergeclient" +) + +func TestLoginStaticCommand(t *testing.T) { + testCA, err := certauthority.New(pkix.Name{CommonName: "Test CA"}, 1*time.Hour) + require.NoError(t, err) + tmpdir := testutil.TempDir(t) + testCABundlePath := filepath.Join(tmpdir, "testca.pem") + require.NoError(t, ioutil.WriteFile(testCABundlePath, testCA.Bundle(), 0600)) + + tests := []struct { + name string + args []string + env map[string]string + loginErr error + conciergeErr error + wantError bool + wantStdout string + wantStderr string + wantOptionsCount int + }{ + { + name: "help flag passed", + args: []string{"--help"}, + wantStdout: here.Doc(` + Login using a static token + + Usage: + static [--token TOKEN] [--token-env TOKEN_NAME] [flags] + + Flags: + --concierge-authenticator-name string Concierge authenticator name + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') + --concierge-ca-bundle-data string CA bundle to use when connecting to the concierge + --concierge-endpoint string API base for the Pinniped concierge endpoint + --concierge-namespace string Namespace in which the concierge was installed (default "pinniped-concierge") + --enable-concierge Exchange the token with the Pinniped concierge during login + -h, --help help for static + --token string Static token to present during login + --token-env string Environment variable containing a static token + `), + }, + { + name: "missing required flags", + args: []string{}, + wantError: true, + wantStdout: here.Doc(` + Error: one of --token or --token-env must be set + `), + }, + { + name: "missing concierge flags", + args: []string{ + "--token", "test-token", + "--enable-concierge", + }, + wantError: true, + wantStdout: here.Doc(` + Error: invalid concierge parameters: endpoint must not be empty + `), + }, + { + name: "missing env var", + args: []string{ + "--token-env", "TEST_TOKEN_ENV", + }, + wantError: true, + wantStdout: here.Doc(` + Error: --token-env variable "TEST_TOKEN_ENV" is not set + `), + }, + { + name: "empty env var", + args: []string{ + "--token-env", "TEST_TOKEN_ENV", + }, + env: map[string]string{ + "TEST_TOKEN_ENV": "", + }, + wantError: true, + wantStdout: here.Doc(` + Error: --token-env variable "TEST_TOKEN_ENV" is empty + `), + }, + { + name: "env var token success", + args: []string{ + "--token-env", "TEST_TOKEN_ENV", + }, + env: map[string]string{ + "TEST_TOKEN_ENV": "test-token", + }, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"test-token"}}` + "\n", + }, + { + name: "concierge failure", + args: []string{ + "--token", "test-token", + "--enable-concierge", + "--concierge-endpoint", "https://127.0.0.1/", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + }, + conciergeErr: fmt.Errorf("some concierge error"), + wantError: true, + wantStdout: here.Doc(` + Error: could not complete concierge credential exchange: some concierge error + `), + }, + { + name: "static token success", + args: []string{ + "--token", "test-token", + }, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"test-token"}}` + "\n", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cmd := staticLoginCommand(staticLoginDeps{ + lookupEnv: func(s string) (string, bool) { + v, ok := tt.env[s] + return v, ok + }, + exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { + require.Equal(t, token, "test-token") + if tt.conciergeErr != nil { + return nil, tt.conciergeErr + } + return &clientauthv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthv1beta1.ExecCredentialStatus{ + Token: "exchanged-token", + }, + }, nil + }, + }) + require.NotNil(t, cmd) + + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + }) + } +}