k8s.io/client-go@v0.22.2/plugin/pkg/client/auth/gcp/gcp.go (about)

     1  /*
     2  Copyright 2016 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package gcp
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"encoding/json"
    23  	"fmt"
    24  	"net/http"
    25  	"os/exec"
    26  	"strings"
    27  	"sync"
    28  	"time"
    29  
    30  	"golang.org/x/oauth2"
    31  	"golang.org/x/oauth2/google"
    32  	"k8s.io/apimachinery/pkg/util/net"
    33  	"k8s.io/apimachinery/pkg/util/yaml"
    34  	restclient "k8s.io/client-go/rest"
    35  	"k8s.io/client-go/util/jsonpath"
    36  	"k8s.io/klog/v2"
    37  )
    38  
    39  func init() {
    40  	if err := restclient.RegisterAuthProviderPlugin("gcp", newGCPAuthProvider); err != nil {
    41  		klog.Fatalf("Failed to register gcp auth plugin: %v", err)
    42  	}
    43  }
    44  
    45  var (
    46  	// Stubbable for testing
    47  	execCommand = exec.Command
    48  
    49  	// defaultScopes:
    50  	// - cloud-platform is the base scope to authenticate to GCP.
    51  	// - userinfo.email is used to authenticate to GKE APIs with gserviceaccount
    52  	//   email instead of numeric uniqueID.
    53  	defaultScopes = []string{
    54  		"https://www.googleapis.com/auth/cloud-platform",
    55  		"https://www.googleapis.com/auth/userinfo.email"}
    56  )
    57  
    58  // gcpAuthProvider is an auth provider plugin that uses GCP credentials to provide
    59  // tokens for kubectl to authenticate itself to the apiserver. A sample json config
    60  // is provided below with all recognized options described.
    61  //
    62  // {
    63  //   'auth-provider': {
    64  //     # Required
    65  //     "name": "gcp",
    66  //
    67  //     'config': {
    68  //       # Authentication options
    69  //       # These options are used while getting a token.
    70  //
    71  //       # comma-separated list of GCP API scopes. default value of this field
    72  //       # is "https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email".
    73  // 		 # to override the API scopes, specify this field explicitly.
    74  //       "scopes": "https://www.googleapis.com/auth/cloud-platform"
    75  //
    76  //       # Caching options
    77  //
    78  //       # Raw string data representing cached access token.
    79  //       "access-token": "ya29.CjWdA4GiBPTt",
    80  //       # RFC3339Nano expiration timestamp for cached access token.
    81  //       "expiry": "2016-10-31 22:31:9.123",
    82  //
    83  //       # Command execution options
    84  //       # These options direct the plugin to execute a specified command and parse
    85  //       # token and expiry time from the output of the command.
    86  //
    87  //       # Command to execute for access token. Command output will be parsed as JSON.
    88  //       # If "cmd-args" is not present, this value will be split on whitespace, with
    89  //       # the first element interpreted as the command, remaining elements as args.
    90  //       "cmd-path": "/usr/bin/gcloud",
    91  //
    92  //       # Arguments to pass to command to execute for access token.
    93  //       "cmd-args": "config config-helper --output=json"
    94  //
    95  //       # JSONPath to the string field that represents the access token in
    96  //       # command output. If omitted, defaults to "{.access_token}".
    97  //       "token-key": "{.credential.access_token}",
    98  //
    99  //       # JSONPath to the string field that represents expiration timestamp
   100  //       # of the access token in the command output. If omitted, defaults to
   101  //       # "{.token_expiry}"
   102  //       "expiry-key": ""{.credential.token_expiry}",
   103  //
   104  //       # golang reference time in the format that the expiration timestamp uses.
   105  //       # If omitted, defaults to time.RFC3339Nano
   106  //       "time-fmt": "2006-01-02 15:04:05.999999999"
   107  //     }
   108  //   }
   109  // }
   110  //
   111  type gcpAuthProvider struct {
   112  	tokenSource oauth2.TokenSource
   113  	persister   restclient.AuthProviderConfigPersister
   114  }
   115  
   116  var warnOnce sync.Once
   117  
   118  func newGCPAuthProvider(_ string, gcpConfig map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) {
   119  	// deprecated in v1.22, remove in v1.25
   120  	// this should be updated to use klog.Warningf in v1.24 to more actively warn consumers
   121  	warnOnce.Do(func() {
   122  		klog.V(1).Infof(`WARNING: the gcp auth plugin is deprecated in v1.22+, unavailable in v1.25+; use gcloud instead.
   123  To learn more, consult https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins`)
   124  	})
   125  
   126  	ts, err := tokenSource(isCmdTokenSource(gcpConfig), gcpConfig)
   127  	if err != nil {
   128  		return nil, err
   129  	}
   130  	cts, err := newCachedTokenSource(gcpConfig["access-token"], gcpConfig["expiry"], persister, ts, gcpConfig)
   131  	if err != nil {
   132  		return nil, err
   133  	}
   134  	return &gcpAuthProvider{cts, persister}, nil
   135  }
   136  
   137  func isCmdTokenSource(gcpConfig map[string]string) bool {
   138  	_, ok := gcpConfig["cmd-path"]
   139  	return ok
   140  }
   141  
   142  func tokenSource(isCmd bool, gcpConfig map[string]string) (oauth2.TokenSource, error) {
   143  	// Command-based token source
   144  	if isCmd {
   145  		cmd := gcpConfig["cmd-path"]
   146  		if len(cmd) == 0 {
   147  			return nil, fmt.Errorf("missing access token cmd")
   148  		}
   149  		if gcpConfig["scopes"] != "" {
   150  			return nil, fmt.Errorf("scopes can only be used when kubectl is using a gcp service account key")
   151  		}
   152  		var args []string
   153  		if cmdArgs, ok := gcpConfig["cmd-args"]; ok {
   154  			args = strings.Fields(cmdArgs)
   155  		} else {
   156  			fields := strings.Fields(cmd)
   157  			cmd = fields[0]
   158  			args = fields[1:]
   159  		}
   160  		return newCmdTokenSource(cmd, args, gcpConfig["token-key"], gcpConfig["expiry-key"], gcpConfig["time-fmt"]), nil
   161  	}
   162  
   163  	// Google Application Credentials-based token source
   164  	scopes := parseScopes(gcpConfig)
   165  	ts, err := google.DefaultTokenSource(context.Background(), scopes...)
   166  	if err != nil {
   167  		return nil, fmt.Errorf("cannot construct google default token source: %v", err)
   168  	}
   169  	return ts, nil
   170  }
   171  
   172  // parseScopes constructs a list of scopes that should be included in token source
   173  // from the config map.
   174  func parseScopes(gcpConfig map[string]string) []string {
   175  	scopes, ok := gcpConfig["scopes"]
   176  	if !ok {
   177  		return defaultScopes
   178  	}
   179  	if scopes == "" {
   180  		return []string{}
   181  	}
   182  	return strings.Split(gcpConfig["scopes"], ",")
   183  }
   184  
   185  func (g *gcpAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper {
   186  	var resetCache map[string]string
   187  	if cts, ok := g.tokenSource.(*cachedTokenSource); ok {
   188  		resetCache = cts.baseCache()
   189  	} else {
   190  		resetCache = make(map[string]string)
   191  	}
   192  	return &conditionalTransport{&oauth2.Transport{Source: g.tokenSource, Base: rt}, g.persister, resetCache}
   193  }
   194  
   195  func (g *gcpAuthProvider) Login() error { return nil }
   196  
   197  type cachedTokenSource struct {
   198  	lk          sync.Mutex
   199  	source      oauth2.TokenSource
   200  	accessToken string `datapolicy:"token"`
   201  	expiry      time.Time
   202  	persister   restclient.AuthProviderConfigPersister
   203  	cache       map[string]string
   204  }
   205  
   206  func newCachedTokenSource(accessToken, expiry string, persister restclient.AuthProviderConfigPersister, ts oauth2.TokenSource, cache map[string]string) (*cachedTokenSource, error) {
   207  	var expiryTime time.Time
   208  	if parsedTime, err := time.Parse(time.RFC3339Nano, expiry); err == nil {
   209  		expiryTime = parsedTime
   210  	}
   211  	if cache == nil {
   212  		cache = make(map[string]string)
   213  	}
   214  	return &cachedTokenSource{
   215  		source:      ts,
   216  		accessToken: accessToken,
   217  		expiry:      expiryTime,
   218  		persister:   persister,
   219  		cache:       cache,
   220  	}, nil
   221  }
   222  
   223  func (t *cachedTokenSource) Token() (*oauth2.Token, error) {
   224  	tok := t.cachedToken()
   225  	if tok.Valid() && !tok.Expiry.IsZero() {
   226  		return tok, nil
   227  	}
   228  	tok, err := t.source.Token()
   229  	if err != nil {
   230  		return nil, err
   231  	}
   232  	cache := t.update(tok)
   233  	if t.persister != nil {
   234  		if err := t.persister.Persist(cache); err != nil {
   235  			klog.V(4).Infof("Failed to persist token: %v", err)
   236  		}
   237  	}
   238  	return tok, nil
   239  }
   240  
   241  func (t *cachedTokenSource) cachedToken() *oauth2.Token {
   242  	t.lk.Lock()
   243  	defer t.lk.Unlock()
   244  	return &oauth2.Token{
   245  		AccessToken: t.accessToken,
   246  		TokenType:   "Bearer",
   247  		Expiry:      t.expiry,
   248  	}
   249  }
   250  
   251  func (t *cachedTokenSource) update(tok *oauth2.Token) map[string]string {
   252  	t.lk.Lock()
   253  	defer t.lk.Unlock()
   254  	t.accessToken = tok.AccessToken
   255  	t.expiry = tok.Expiry
   256  	ret := map[string]string{}
   257  	for k, v := range t.cache {
   258  		ret[k] = v
   259  	}
   260  	ret["access-token"] = t.accessToken
   261  	ret["expiry"] = t.expiry.Format(time.RFC3339Nano)
   262  	return ret
   263  }
   264  
   265  // baseCache is the base configuration value for this TokenSource, without any cached ephemeral tokens.
   266  func (t *cachedTokenSource) baseCache() map[string]string {
   267  	t.lk.Lock()
   268  	defer t.lk.Unlock()
   269  	ret := map[string]string{}
   270  	for k, v := range t.cache {
   271  		ret[k] = v
   272  	}
   273  	delete(ret, "access-token")
   274  	delete(ret, "expiry")
   275  	return ret
   276  }
   277  
   278  type commandTokenSource struct {
   279  	cmd       string
   280  	args      []string
   281  	tokenKey  string `datapolicy:"token"`
   282  	expiryKey string `datapolicy:"secret-key"`
   283  	timeFmt   string
   284  }
   285  
   286  func newCmdTokenSource(cmd string, args []string, tokenKey, expiryKey, timeFmt string) *commandTokenSource {
   287  	if len(timeFmt) == 0 {
   288  		timeFmt = time.RFC3339Nano
   289  	}
   290  	if len(tokenKey) == 0 {
   291  		tokenKey = "{.access_token}"
   292  	}
   293  	if len(expiryKey) == 0 {
   294  		expiryKey = "{.token_expiry}"
   295  	}
   296  	return &commandTokenSource{
   297  		cmd:       cmd,
   298  		args:      args,
   299  		tokenKey:  tokenKey,
   300  		expiryKey: expiryKey,
   301  		timeFmt:   timeFmt,
   302  	}
   303  }
   304  
   305  func (c *commandTokenSource) Token() (*oauth2.Token, error) {
   306  	fullCmd := strings.Join(append([]string{c.cmd}, c.args...), " ")
   307  	cmd := execCommand(c.cmd, c.args...)
   308  	var stderr bytes.Buffer
   309  	cmd.Stderr = &stderr
   310  	output, err := cmd.Output()
   311  	if err != nil {
   312  		return nil, fmt.Errorf("error executing access token command %q: err=%v output=%s stderr=%s", fullCmd, err, output, string(stderr.Bytes()))
   313  	}
   314  	token, err := c.parseTokenCmdOutput(output)
   315  	if err != nil {
   316  		return nil, fmt.Errorf("error parsing output for access token command %q: %v", fullCmd, err)
   317  	}
   318  	return token, nil
   319  }
   320  
   321  func (c *commandTokenSource) parseTokenCmdOutput(output []byte) (*oauth2.Token, error) {
   322  	output, err := yaml.ToJSON(output)
   323  	if err != nil {
   324  		return nil, err
   325  	}
   326  	var data interface{}
   327  	if err := json.Unmarshal(output, &data); err != nil {
   328  		return nil, err
   329  	}
   330  
   331  	accessToken, err := parseJSONPath(data, "token-key", c.tokenKey)
   332  	if err != nil {
   333  		return nil, fmt.Errorf("error parsing token-key %q from %q: %v", c.tokenKey, string(output), err)
   334  	}
   335  	expiryStr, err := parseJSONPath(data, "expiry-key", c.expiryKey)
   336  	if err != nil {
   337  		return nil, fmt.Errorf("error parsing expiry-key %q from %q: %v", c.expiryKey, string(output), err)
   338  	}
   339  	var expiry time.Time
   340  	if t, err := time.Parse(c.timeFmt, expiryStr); err != nil {
   341  		klog.V(4).Infof("Failed to parse token expiry from %s (fmt=%s): %v", expiryStr, c.timeFmt, err)
   342  	} else {
   343  		expiry = t
   344  	}
   345  
   346  	return &oauth2.Token{
   347  		AccessToken: accessToken,
   348  		TokenType:   "Bearer",
   349  		Expiry:      expiry,
   350  	}, nil
   351  }
   352  
   353  func parseJSONPath(input interface{}, name, template string) (string, error) {
   354  	j := jsonpath.New(name)
   355  	buf := new(bytes.Buffer)
   356  	if err := j.Parse(template); err != nil {
   357  		return "", err
   358  	}
   359  	if err := j.Execute(buf, input); err != nil {
   360  		return "", err
   361  	}
   362  	return buf.String(), nil
   363  }
   364  
   365  type conditionalTransport struct {
   366  	oauthTransport *oauth2.Transport
   367  	persister      restclient.AuthProviderConfigPersister
   368  	resetCache     map[string]string
   369  }
   370  
   371  var _ net.RoundTripperWrapper = &conditionalTransport{}
   372  
   373  func (t *conditionalTransport) RoundTrip(req *http.Request) (*http.Response, error) {
   374  	if len(req.Header.Get("Authorization")) != 0 {
   375  		return t.oauthTransport.Base.RoundTrip(req)
   376  	}
   377  
   378  	res, err := t.oauthTransport.RoundTrip(req)
   379  
   380  	if err != nil {
   381  		return nil, err
   382  	}
   383  
   384  	if res.StatusCode == 401 {
   385  		klog.V(4).Infof("The credentials that were supplied are invalid for the target cluster")
   386  		t.persister.Persist(t.resetCache)
   387  	}
   388  
   389  	return res, nil
   390  }
   391  
   392  func (t *conditionalTransport) WrappedRoundTripper() http.RoundTripper { return t.oauthTransport.Base }