Skip to content

Commit

Permalink
Merge pull request #1658 from ardaguclu/oc-cred-exec-plugin-4.15
Browse files Browse the repository at this point in the history
[release-4.15] WRKLDS-1041: oc login: Built-in cred exec plugin implementation and wiring
  • Loading branch information
openshift-merge-bot[bot] committed Jan 22, 2024
2 parents e31bee5 + e1b9294 commit 9ff9bd7
Show file tree
Hide file tree
Showing 72 changed files with 12,684 additions and 136 deletions.
16 changes: 10 additions & 6 deletions go.mod
Expand Up @@ -13,6 +13,7 @@ require (
github.com/aws/aws-sdk-go v1.45.20
github.com/blang/semver v3.5.1+incompatible
github.com/containers/image/v5 v5.29.0
github.com/coreos/go-oidc/v3 v3.9.0
github.com/davecgh/go-spew v1.1.1
github.com/distribution/distribution/v3 v3.0.0-20230519140516-983358f8e250
github.com/docker/docker v24.0.7+incompatible
Expand All @@ -25,6 +26,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.3
github.com/gonum/graph v0.0.0-20170401004347-50b27dea7ebb
github.com/google/go-cmp v0.6.0
github.com/int128/oauth2cli v1.14.0
github.com/joelanford/ignore v0.0.0-20210610194209-63d4919d8fb2
github.com/moby/buildkit v0.0.0-20181107081847-c3a857e3fca0
github.com/moby/sys/sequential v0.5.0
Expand All @@ -42,9 +44,11 @@ require (
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/vincent-petithory/dataurl v1.0.0
golang.org/x/crypto v0.16.0
golang.org/x/net v0.19.0
golang.org/x/sys v0.15.0
golang.org/x/crypto v0.18.0
golang.org/x/net v0.20.0
golang.org/x/oauth2 v0.16.0
golang.org/x/sync v0.5.0
golang.org/x/sys v0.16.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.28.4
k8s.io/apiextensions-apiserver v0.28.2
Expand Down Expand Up @@ -91,6 +95,7 @@ require (
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.1.0 // indirect
github.com/go-git/go-git/v5 v5.3.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-openapi/analysis v0.21.4 // indirect
github.com/go-openapi/errors v0.20.4 // indirect
Expand Down Expand Up @@ -125,6 +130,7 @@ require (
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/int128/listener v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
Expand Down Expand Up @@ -175,9 +181,7 @@ require (
go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/oauth2 v0.14.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/term v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.14.0 // indirect
Expand Down
281 changes: 271 additions & 10 deletions go.sum

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pkg/cli/cli.go
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/openshift/oc/pkg/cli/deployer"
"github.com/openshift/oc/pkg/cli/expose"
"github.com/openshift/oc/pkg/cli/extract"
"github.com/openshift/oc/pkg/cli/gettoken"
"github.com/openshift/oc/pkg/cli/idle"
"github.com/openshift/oc/pkg/cli/image"
"github.com/openshift/oc/pkg/cli/importimage"
Expand Down Expand Up @@ -313,6 +314,7 @@ func NewOcCommand(o kubecmd.KubectlOptions) *cobra.Command {
{
Message: "Settings Commands:",
Commands: []*cobra.Command{
gettoken.NewCmdGetToken(f, o.IOStreams),
logout.NewCmdLogout(f, o.IOStreams),
kubectlwrappers.NewCmdConfig(f, o.IOStreams),
whoami.NewCmdWhoAmI(f, o.IOStreams),
Expand Down
44 changes: 44 additions & 0 deletions pkg/cli/gettoken/credwriter/credwriter.go
@@ -0,0 +1,44 @@
package credwriter

import (
"encoding/json"
"fmt"
"io"
"time"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/cli-runtime/pkg/genericiooptions"
v1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
)

// Writer writes ExecCredentials and basically
// populates the status with the retrieved
// token and expiration time.
type Writer struct {
out io.Writer
}

func NewWriter(iostreams genericiooptions.IOStreams) *Writer {
return &Writer{
out: iostreams.Out,
}
}

// Write writes the ExecCredential to standard output for oc.
func (w *Writer) Write(token string, expiry time.Time) error {
ec := &v1.ExecCredential{
TypeMeta: metav1.TypeMeta{
APIVersion: "client.authentication.k8s.io/v1",
Kind: "ExecCredential",
},
Status: &v1.ExecCredentialStatus{
Token: token,
ExpirationTimestamp: &metav1.Time{Time: expiry},
},
}
e := json.NewEncoder(w.out)
if err := e.Encode(ec); err != nil {
return fmt.Errorf("could not write the ExecCredential: %w", err)
}
return nil
}
266 changes: 266 additions & 0 deletions pkg/cli/gettoken/gettoken.go
@@ -0,0 +1,266 @@
package gettoken

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"

gooidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
"golang.org/x/sync/errgroup"

"k8s.io/cli-runtime/pkg/genericiooptions"
"k8s.io/client-go/util/homedir"
"k8s.io/klog/v2"
kcmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/util/templates"

"github.com/openshift/oc/pkg/cli/gettoken/credwriter"
"github.com/openshift/oc/pkg/cli/gettoken/oidc"
"github.com/openshift/oc/pkg/cli/gettoken/tokencache"
)

var (
getTokenLong = templates.LongDesc(`
Experimental: This command is under development and may change without notice.
Built-in Credential Exec plugin of the oc.
It supports Auth Code, Auth Code + PKCE in addition to refresh token.
get-token caches the ID token and Refresh token after the auth code flow is
successfully completed and once ID token expires, command tries to get the
new token by using the refresh token flow. Although it is optional, command
also supports getting client secret to behave as an confidential client.
`)
getTokenExample = templates.Examples(`
# Starts an auth code flow to the issuer url with the client id and the given extra scopes
oc get-token --client-id=client-id --issuer-url=test.issuer.url --extra-scopes=email,profile
# Starts an authe code flow to the issuer url with a different callback address.
oc get-token --client-id=client-id --issuer-url=test.issuer.url --callback-address=127.0.0.1:8343
`)
)

const defaultCallbackAddress = "127.0.0.1:0"

type GetTokenOptions struct {
IssuerURL string
ClientID string
ClientSecret string
ExtraScopes []string
CallbackAdress string
CACertFilename string
InsecureTLS bool
AutoOpenBrowser bool

authenticator oidc.Authenticator
tokenCache *tokencache.Repository
credWriter *credwriter.Writer
tokenCacheDir string
authenticationTimeout time.Duration

genericiooptions.IOStreams
}

func NewGetTokenOptions(streams genericiooptions.IOStreams) *GetTokenOptions {
return &GetTokenOptions{
IOStreams: streams,
CallbackAdress: defaultCallbackAddress,
authenticationTimeout: 5 * time.Minute,
}
}

func NewCmdGetToken(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Command {
o := NewGetTokenOptions(streams)

cmd := &cobra.Command{
Use: "get-token --oidc-client-id=CLIENT_ID --oidc-issuer-url=ISSUER_URL",
Short: "Experimental: Get token from external OIDC issuer as credentials exec plugin",
Long: getTokenLong,
Example: getTokenExample,
Run: func(cmd *cobra.Command, args []string) {
kcmdutil.CheckErr(o.Complete(f, cmd, args))
kcmdutil.CheckErr(o.Validate())
kcmdutil.CheckErr(o.Run())
},
}

cmd.Flags().StringVar(&o.IssuerURL, "issuer-url", o.IssuerURL, "Issuer URL of the external OIDC provider")
cmd.Flags().StringVar(&o.ClientID, "client-id", o.ClientID, "Client ID of the user managed by the external OIDC provider")
cmd.Flags().StringVar(&o.ClientSecret, "client-secret", o.ClientSecret, "Client Secret of the user managed by the external OIDC provider. Optional.")
cmd.Flags().StringSliceVar(&o.ExtraScopes, "extra-scopes", o.ExtraScopes, "Extra scopes for the auth request to the external OIDC provider. Optional.")
cmd.Flags().StringVar(&o.CallbackAdress, "callback-address", o.CallbackAdress, "Callback address where external OIDC issuer redirects to after flow is completed. Defaults to 127.0.0.1:0 to pick a random port.")
cmd.Flags().BoolVar(&o.AutoOpenBrowser, "auto-open-browser", o.AutoOpenBrowser, "Specify browser is automatically opened or not.")

return cmd
}

func (o *GetTokenOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []string) error {
o.CACertFilename = kcmdutil.GetFlagString(cmd, "certificate-authority")
o.InsecureTLS = kcmdutil.GetFlagBool(cmd, "insecure-skip-tls-verify")

provider := &oidc.Provider{
IssuerURL: o.IssuerURL,
ClientID: o.ClientID,
ClientSecret: o.ClientSecret,
ExtraScopes: o.ExtraScopes,
UsePKCE: true,
}

authenticator, err := oidc.NewAuthenticator(context.Background(), provider, "", o.CACertFilename, o.InsecureTLS)
if err != nil {
return fmt.Errorf("oidc authenticator error: %w", err)
}

o.authenticator = authenticator
o.tokenCache = &tokencache.Repository{}
o.credWriter = credwriter.NewWriter(o.IOStreams)

o.tokenCacheDir = filepath.Join(homedir.HomeDir(), ".kube", "cache", "oc")
if kcd := os.Getenv("KUBECACHEDIR"); kcd != "" {
o.tokenCacheDir = filepath.Join(kcd, "oc")
}

return nil
}

func (o *GetTokenOptions) Validate() error {
if o.IssuerURL == "" {
return fmt.Errorf("--issuer-url is required")
}
if o.ClientID == "" {
return fmt.Errorf("--client-id is required")
}

return nil
}

// Run starts the authentication flow with a caching and refreshing capability.
// If refresh token is found, it tries to use it to get a valid id token from
// external OIDC issuer. If not, it forces user to log in.
func (o *GetTokenOptions) Run() error {
tokenCacheKey := tokencache.Key{
IssuerURL: o.IssuerURL,
ClientID: o.ClientID,
}

// Ignoring the error because if there is any error occurred
// other than the missed cache, it will be captured while writing the token.
tokenSet, err := o.tokenCache.FindByKey(o.tokenCacheDir, tokenCacheKey)
if err != nil {
// If we get the file not found error, this can be the first time
// user authenticates and we can continue authentication.
// If we get any other error, we should return this by short cutting.
if !os.IsNotExist(err) {
return err
}
}
alreadyValid, idToken, refreshToken, expiry, err := o.getToken(context.Background(), tokenSet)
if err != nil {
return err
}

if !alreadyValid {
err = o.tokenCache.Save(o.tokenCacheDir, tokenCacheKey, tokencache.Set{
IDToken: idToken,
RefreshToken: refreshToken,
})
if err != nil {
return fmt.Errorf("failed to write to token cache")
}
}

if err := o.credWriter.Write(idToken, expiry); err != nil {
return fmt.Errorf("failed to write the token to client-go: %w", err)
}
return nil
}

// getToken checks the id token in the passed cache object and it returns if it is not expired.
// If the cached token is expired, it checks first the refresh token's existence.
// If the refresh token is present, it tries to get the id token by using the refresh token
// in a token refresh flow. If none of the above steps succeeds, it triggers a new auth code
// token process.
func (o *GetTokenOptions) getToken(ctx context.Context, cache *tokencache.Set) (bool, string, string, time.Time, error) {
if cache == nil {
idToken, refreshToken, expiry, err := o.doAuthCode(ctx)
return false, idToken, refreshToken, expiry, err
}

if cache.IDToken != "" {
extra := make(map[string]interface{})
extra["id_token"] = cache.IDToken
t := &oauth2.Token{}
t = t.WithExtra(extra)
_, expiry, err := o.authenticator.VerifyToken(ctx, t, "")
if err == nil {
return true, cache.IDToken, cache.RefreshToken, expiry, nil
}
tokenExpiredError := &gooidc.TokenExpiredError{}
if !errors.As(err, &tokenExpiredError) {
return false, "", "", time.Time{}, err
}
}

if cache.RefreshToken != "" {
idToken, refreshToken, expiry, err := o.authenticator.Refresh(ctx, cache.RefreshToken)
if err != nil {
klog.V(2).Infof("refreshing token failed: %v, we'll attempt to do the auth code grant flow", err)
} else {
return false, idToken, refreshToken, expiry, nil
}
}

idToken, refreshToken, expiry, err := o.doAuthCode(ctx)
return false, idToken, refreshToken, expiry, err
}

// doAuthCode does the auth code flow with PKCE(if the issuer supports it).
func (o *GetTokenOptions) doAuthCode(ctx context.Context) (string, string, time.Time, error) {
ctx, cancel := context.WithTimeout(ctx, o.authenticationTimeout)
defer cancel()
readyChan := make(chan string, 1)
var idToken, refreshToken string
var expiry time.Time
var eg errgroup.Group
eg.Go(func() error {
select {
case url, ok := <-readyChan:
if !ok {
return nil
}

if !o.AutoOpenBrowser {
// We are writing this to ErrOut instead of Out because Out is listened by client-go to get token.
fmt.Fprintf(o.IOStreams.ErrOut, "Please visit the following URL in your browser: %s\n", url)
return nil
}

err := browser.OpenURL(url)
if err != nil {
// We are writing this to ErrOut instead of Out because Out is listened by client-go to get token.
fmt.Fprintf(o.IOStreams.ErrOut, "error: could not open the browser: %s\n\nlease visit the following URL in your browser manually: %s", err, url)
}
return nil
case <-ctx.Done():
return fmt.Errorf("context cancelled while waiting for the local server: %w", ctx.Err())
}
})
eg.Go(func() error {
defer close(readyChan)
var authErr error
idToken, refreshToken, expiry, authErr = o.authenticator.GetTokenByAuthCode(ctx, o.CallbackAdress, readyChan)
if authErr != nil {
return fmt.Errorf("authorization code flow error: %w", authErr)
}
return nil
})
if err := eg.Wait(); err != nil {
return "", "", time.Time{}, fmt.Errorf("authentication error: %w", err)
}
return idToken, refreshToken, expiry, nil
}

0 comments on commit 9ff9bd7

Please sign in to comment.