diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index c8b2b0cc..6de74ee8 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -125,7 +125,7 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { } func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLoginFlags) error { //nolint:funlen - pLogger, err := SetLogLevel(deps.lookupEnv) + pLogger, err := SetLogLevel(deps.lookupEnv, "Pinniped login: ") if err != nil { plog.WarningErr("Received error while setting log level", err) } @@ -326,7 +326,7 @@ func tokenCredential(token *oidctypes.Token) *clientauthv1beta1.ExecCredential { return &cred } -func SetLogLevel(lookupEnv func(string) (string, bool)) (plog.Logger, error) { +func SetLogLevel(lookupEnv func(string) (string, bool), prefix string) (plog.Logger, error) { debug, _ := lookupEnv("PINNIPED_DEBUG") if debug == "true" { err := plog.ValidateAndSetLogLevelGlobally(plog.LevelDebug) @@ -334,7 +334,7 @@ func SetLogLevel(lookupEnv func(string) (string, bool)) (plog.Logger, error) { return nil, err } } - logger := plog.New("Pinniped login: ") + logger := plog.New(prefix) return logger, nil } diff --git a/cmd/pinniped/cmd/login_static.go b/cmd/pinniped/cmd/login_static.go index 3642ffe1..2ce3ccf7 100644 --- a/cmd/pinniped/cmd/login_static.go +++ b/cmd/pinniped/cmd/login_static.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -84,7 +84,7 @@ func staticLoginCommand(deps staticLoginDeps) *cobra.Command { } func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams) error { - pLogger, err := SetLogLevel(deps.lookupEnv) + pLogger, err := SetLogLevel(deps.lookupEnv, "Pinniped login: ") if err != nil { plog.WarningErr("Received error while setting log level", err) } diff --git a/cmd/pinniped/cmd/logout.go b/cmd/pinniped/cmd/logout.go new file mode 100644 index 00000000..9253342d --- /dev/null +++ b/cmd/pinniped/cmd/logout.go @@ -0,0 +1,153 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package cmd + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + + "go.pinniped.dev/internal/plog" + + coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/spf13/cobra" + + "go.pinniped.dev/pkg/oidcclient" + "go.pinniped.dev/pkg/oidcclient/filesession" +) + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(newLogoutCommand()) +} + +type logoutFlags struct { + kubeconfigPath string + kubeconfigContextOverride string +} + +// This implements client side logout-- i.e. deleting the cached tokens and certificates for a user +// without telling the supervisor to forget about the users tokens. From a user experience +// perspective these are identical, but it leaves orphaned tokens lying around that the supervisor +// won't garbage collect for up to 9 hours. +// Fosite supports token revocation requests ala https://tools.ietf.org/html/rfc7009#section-2.1 +// with their TokenRevocationHandler, but we would also want to turn around and revoke the upstream +// tokens in the case of OIDC. +// That's something that could be done to improve security and stop storage from getting too +// big. +// It works by parsing the provided kubeconfig to get the arguments to pinniped login oidc, +// grabbing the issuer and the cache paths, then using that issuer to find and delete the entry +// in the session cache. +func newLogoutCommand() *cobra.Command { + cmd := &cobra.Command{ + Args: cobra.NoArgs, + Use: "logout", + Short: "Terminate the current user's session.", + } + flags := &logoutFlags{} + + cmd.Flags().StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file") + cmd.Flags().StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runLogout(flags) + } + return cmd +} + +func runLogout(flags *logoutFlags) error { + pLogger, err := SetLogLevel(os.LookupEnv, "Pinniped logout: ") + if err != nil { + plog.WarningErr("Received error while setting log level", err) + } + clientConfig := newClientConfig(flags.kubeconfigPath, flags.kubeconfigContextOverride) + currentKubeConfig, err := clientConfig.RawConfig() + if err != nil { + return err + } + + // start by getting the current context or another context if provided. + contextName := currentKubeConfig.CurrentContext + if len(flags.kubeconfigContextOverride) > 0 { + contextName = flags.kubeconfigContextOverride + } + kubeContext, ok := currentKubeConfig.Contexts[contextName] + if !ok { + return fmt.Errorf("couldn't find current context") + } + + // then get the authinfo associated with that context. + authInfo := currentKubeConfig.AuthInfos[kubeContext.AuthInfo] + if authInfo == nil { + return fmt.Errorf("could not find auth info-- are you sure this is a Pinniped kubeconfig?") + } + + // get the exec credential out of the authinfo and validate that it takes the shape of a pinniped login command. + exec := authInfo.Exec + if exec == nil { + return fmt.Errorf("could not find exec credential-- are you sure this is a Pinniped kubeconfig?") + } + execArgs := exec.Args + if execArgs == nil { + return fmt.Errorf("could not find exec credential arguments-- are you sure this is a Pinniped kubeconfig?") + } + + // parse the arguments in the exec credential (which should be the pinniped login command). + loginCommand := oidcLoginCommand(oidcLoginCommandDeps{}) + err = loginCommand.ParseFlags(execArgs) + if err != nil { + return err + } + // Get the issuer flag. If this doesn't exist we have no way to get in to the cache so we have to exit. + issuer := loginCommand.Flag("issuer").Value.String() + if issuer == "" { + return fmt.Errorf("could not find issuer-- are you sure this is a Pinniped kubeconfig?") + } + + // Get the session cache. If it doesn't exist just use the default value. + sessionCachePath := loginCommand.Flag("session-cache").Value.String() + if sessionCachePath == "" { + sessionCachePath = filepath.Join(mustGetConfigDir(), "sessions.yaml") + } + // Get the credential cache. If it doesn't exist just use the default value. + credentialCachePath := loginCommand.Flag("credential-cache").Value.String() + if credentialCachePath == "" { + credentialCachePath = filepath.Join(mustGetConfigDir(), "credentials.yaml") + } + + // TODO this should probably be a more targeted removal rather than the whole file... + // but that involves figuring out the cache key which is hard. + // Remove the credential cache that stores the users x509 certificates. + err = os.Remove(credentialCachePath) + // a not found error is fine and we should move on and try to delete the + // session cache if possible. Other errors might be a problem. + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + // Remove the cache entry for this issuer. + var sessionOptions []filesession.Option + sessionCache := filesession.New(sessionCachePath, sessionOptions...) + downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience"} + sort.Strings(downstreamScopes) + sessionCacheKey := oidcclient.SessionCacheKey{ + Issuer: issuer, + ClientID: "pinniped-cli", + Scopes: downstreamScopes, + RedirectURI: (&url.URL{Scheme: "http", Host: "localhost:0", Path: "/callback"}).String(), + } + deleted := sessionCache.DeleteToken(sessionCacheKey) + + if deleted { + pLogger.Warning("Successfully logged out of session.") + } else { + // this is likely because you're already logged out, but you might still want to know. + pLogger.Warning("Could not find session to log out of.") + pLogger.Debug("debug info", "issuer", issuer, "session cache path", sessionCachePath, "credential cache path", credentialCachePath) + } + + return nil +} diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 6965d0c7..67726648 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -333,6 +333,7 @@ popd >/dev/null manifest=/tmp/pinniped-concierge.yaml concierge_app_name="pinniped-concierge" concierge_namespace="concierge" +image_repo="$registry_repo" webhook_url="https://local-user-authenticator.local-user-authenticator.svc/authenticate" webhook_ca_bundle="$(kubectl get secret local-user-authenticator-tls-serving-certificate --namespace local-user-authenticator -o 'jsonpath={.data.caCertificate}')" discovery_url="$(TERM=dumb kubectl cluster-info | awk '/master|control plane/ {print $NF}')" diff --git a/pkg/oidcclient/filesession/cachefile.go b/pkg/oidcclient/filesession/cachefile.go index 9ea46bc0..dba1e9b7 100644 --- a/pkg/oidcclient/filesession/cachefile.go +++ b/pkg/oidcclient/filesession/cachefile.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package cachefile implements the file format for session caches. @@ -152,6 +152,19 @@ func (c *sessionCache) lookup(key oidcclient.SessionCacheKey) *sessionEntry { return nil } +// delete a cache entry by key. Returns whether it successfully deleted an entry. +func (c *sessionCache) delete(key oidcclient.SessionCacheKey) bool { + length := len(c.Sessions) + for i := range c.Sessions { + if reflect.DeepEqual(c.Sessions[i].Key, key) { + c.Sessions[i] = c.Sessions[length-1] + c.Sessions = c.Sessions[:length-1] + return true + } + } + return false +} + // insert a cache entry. func (c *sessionCache) insert(entries ...sessionEntry) { c.Sessions = append(c.Sessions, entries...) diff --git a/pkg/oidcclient/filesession/filesession.go b/pkg/oidcclient/filesession/filesession.go index 3417a123..8f8dbe95 100644 --- a/pkg/oidcclient/filesession/filesession.go +++ b/pkg/oidcclient/filesession/filesession.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package filesession implements a simple YAML file-based login.sessionCache. @@ -113,6 +113,22 @@ func (c *Cache) PutToken(key oidcclient.SessionCacheKey, token *oidctypes.Token) }) } +// DeleteToken deletes the token from the session cache at the given cache cache key. +// It returns whether it deleted a token. +func (c *Cache) DeleteToken(key oidcclient.SessionCacheKey) bool { + _, err := os.Stat(c.path) + if errors.Is(err, os.ErrNotExist) { + // if the cache file doesn't exist there's no session info + // to delete + return false + } + deleted := false + c.withCache(func(cache *sessionCache) { + deleted = cache.delete(key) + }) + return deleted +} + // withCache is an internal helper which locks, reads the cache, processes/mutates it with the provided function, then // saves it back to the file. func (c *Cache) withCache(transact func(*sessionCache)) {