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 <moyerm@vmware.com>
This commit is contained in:
parent
dfbb5b60de
commit
71850419c1
136
cmd/pinniped/cmd/deprecated.go
Normal file
136
cmd/pinniped/cmd/deprecated.go
Normal file
@ -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
|
||||
}
|
20
cmd/pinniped/cmd/get.go
Normal file
20
cmd/pinniped/cmd/get.go
Normal file
@ -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)
|
||||
}
|
364
cmd/pinniped/cmd/kubeconfig.go
Normal file
364
cmd/pinniped/cmd/kubeconfig.go
Normal file
@ -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
|
||||
}
|
524
cmd/pinniped/cmd/kubeconfig_test.go
Normal file
524
cmd/pinniped/cmd/kubeconfig_test.go
Normal file
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
120
cmd/pinniped/cmd/login_static.go
Normal file
120
cmd/pinniped/cmd/login_static.go
Normal file
@ -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)
|
||||
}
|
180
cmd/pinniped/cmd/login_static_test.go
Normal file
180
cmd/pinniped/cmd/login_static_test.go
Normal file
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user