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 }