1/*
2Copyright 2016 The Kubernetes Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17package gcp
18
19import (
20	"bytes"
21	"context"
22	"encoding/json"
23	"fmt"
24	"net/http"
25	"os/exec"
26	"strings"
27	"sync"
28	"time"
29
30	"github.com/golang/glog"
31	"golang.org/x/oauth2"
32	"golang.org/x/oauth2/google"
33	"k8s.io/apimachinery/pkg/util/net"
34	"k8s.io/apimachinery/pkg/util/yaml"
35	restclient "k8s.io/client-go/rest"
36	"k8s.io/client-go/util/jsonpath"
37)
38
39func init() {
40	if err := restclient.RegisterAuthProviderPlugin("gcp", newGCPAuthProvider); err != nil {
41		glog.Fatalf("Failed to register gcp auth plugin: %v", err)
42	}
43}
44
45var (
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//
111type gcpAuthProvider struct {
112	tokenSource oauth2.TokenSource
113	persister   restclient.AuthProviderConfigPersister
114}
115
116func newGCPAuthProvider(_ string, gcpConfig map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) {
117	ts, err := tokenSource(isCmdTokenSource(gcpConfig), gcpConfig)
118	if err != nil {
119		return nil, err
120	}
121	cts, err := newCachedTokenSource(gcpConfig["access-token"], gcpConfig["expiry"], persister, ts, gcpConfig)
122	if err != nil {
123		return nil, err
124	}
125	return &gcpAuthProvider{cts, persister}, nil
126}
127
128func isCmdTokenSource(gcpConfig map[string]string) bool {
129	_, ok := gcpConfig["cmd-path"]
130	return ok
131}
132
133func tokenSource(isCmd bool, gcpConfig map[string]string) (oauth2.TokenSource, error) {
134	// Command-based token source
135	if isCmd {
136		cmd := gcpConfig["cmd-path"]
137		if len(cmd) == 0 {
138			return nil, fmt.Errorf("missing access token cmd")
139		}
140		if gcpConfig["scopes"] != "" {
141			return nil, fmt.Errorf("scopes can only be used when kubectl is using a gcp service account key")
142		}
143		var args []string
144		if cmdArgs, ok := gcpConfig["cmd-args"]; ok {
145			args = strings.Fields(cmdArgs)
146		} else {
147			fields := strings.Fields(cmd)
148			cmd = fields[0]
149			args = fields[1:]
150		}
151		return newCmdTokenSource(cmd, args, gcpConfig["token-key"], gcpConfig["expiry-key"], gcpConfig["time-fmt"]), nil
152	}
153
154	// Google Application Credentials-based token source
155	scopes := parseScopes(gcpConfig)
156	ts, err := google.DefaultTokenSource(context.Background(), scopes...)
157	if err != nil {
158		return nil, fmt.Errorf("cannot construct google default token source: %v", err)
159	}
160	return ts, nil
161}
162
163// parseScopes constructs a list of scopes that should be included in token source
164// from the config map.
165func parseScopes(gcpConfig map[string]string) []string {
166	scopes, ok := gcpConfig["scopes"]
167	if !ok {
168		return defaultScopes
169	}
170	if scopes == "" {
171		return []string{}
172	}
173	return strings.Split(gcpConfig["scopes"], ",")
174}
175
176func (g *gcpAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper {
177	return &conditionalTransport{&oauth2.Transport{Source: g.tokenSource, Base: rt}, g.persister}
178}
179
180func (g *gcpAuthProvider) Login() error { return nil }
181
182type cachedTokenSource struct {
183	lk          sync.Mutex
184	source      oauth2.TokenSource
185	accessToken string
186	expiry      time.Time
187	persister   restclient.AuthProviderConfigPersister
188	cache       map[string]string
189}
190
191func newCachedTokenSource(accessToken, expiry string, persister restclient.AuthProviderConfigPersister, ts oauth2.TokenSource, cache map[string]string) (*cachedTokenSource, error) {
192	var expiryTime time.Time
193	if parsedTime, err := time.Parse(time.RFC3339Nano, expiry); err == nil {
194		expiryTime = parsedTime
195	}
196	if cache == nil {
197		cache = make(map[string]string)
198	}
199	return &cachedTokenSource{
200		source:      ts,
201		accessToken: accessToken,
202		expiry:      expiryTime,
203		persister:   persister,
204		cache:       cache,
205	}, nil
206}
207
208func (t *cachedTokenSource) Token() (*oauth2.Token, error) {
209	tok := t.cachedToken()
210	if tok.Valid() && !tok.Expiry.IsZero() {
211		return tok, nil
212	}
213	tok, err := t.source.Token()
214	if err != nil {
215		return nil, err
216	}
217	cache := t.update(tok)
218	if t.persister != nil {
219		if err := t.persister.Persist(cache); err != nil {
220			glog.V(4).Infof("Failed to persist token: %v", err)
221		}
222	}
223	return tok, nil
224}
225
226func (t *cachedTokenSource) cachedToken() *oauth2.Token {
227	t.lk.Lock()
228	defer t.lk.Unlock()
229	return &oauth2.Token{
230		AccessToken: t.accessToken,
231		TokenType:   "Bearer",
232		Expiry:      t.expiry,
233	}
234}
235
236func (t *cachedTokenSource) update(tok *oauth2.Token) map[string]string {
237	t.lk.Lock()
238	defer t.lk.Unlock()
239	t.accessToken = tok.AccessToken
240	t.expiry = tok.Expiry
241	ret := map[string]string{}
242	for k, v := range t.cache {
243		ret[k] = v
244	}
245	ret["access-token"] = t.accessToken
246	ret["expiry"] = t.expiry.Format(time.RFC3339Nano)
247	return ret
248}
249
250type commandTokenSource struct {
251	cmd       string
252	args      []string
253	tokenKey  string
254	expiryKey string
255	timeFmt   string
256}
257
258func newCmdTokenSource(cmd string, args []string, tokenKey, expiryKey, timeFmt string) *commandTokenSource {
259	if len(timeFmt) == 0 {
260		timeFmt = time.RFC3339Nano
261	}
262	if len(tokenKey) == 0 {
263		tokenKey = "{.access_token}"
264	}
265	if len(expiryKey) == 0 {
266		expiryKey = "{.token_expiry}"
267	}
268	return &commandTokenSource{
269		cmd:       cmd,
270		args:      args,
271		tokenKey:  tokenKey,
272		expiryKey: expiryKey,
273		timeFmt:   timeFmt,
274	}
275}
276
277func (c *commandTokenSource) Token() (*oauth2.Token, error) {
278	fullCmd := strings.Join(append([]string{c.cmd}, c.args...), " ")
279	cmd := execCommand(c.cmd, c.args...)
280	var stderr bytes.Buffer
281	cmd.Stderr = &stderr
282	output, err := cmd.Output()
283	if err != nil {
284		return nil, fmt.Errorf("error executing access token command %q: err=%v output=%s stderr=%s", fullCmd, err, output, string(stderr.Bytes()))
285	}
286	token, err := c.parseTokenCmdOutput(output)
287	if err != nil {
288		return nil, fmt.Errorf("error parsing output for access token command %q: %v", fullCmd, err)
289	}
290	return token, nil
291}
292
293func (c *commandTokenSource) parseTokenCmdOutput(output []byte) (*oauth2.Token, error) {
294	output, err := yaml.ToJSON(output)
295	if err != nil {
296		return nil, err
297	}
298	var data interface{}
299	if err := json.Unmarshal(output, &data); err != nil {
300		return nil, err
301	}
302
303	accessToken, err := parseJSONPath(data, "token-key", c.tokenKey)
304	if err != nil {
305		return nil, fmt.Errorf("error parsing token-key %q from %q: %v", c.tokenKey, string(output), err)
306	}
307	expiryStr, err := parseJSONPath(data, "expiry-key", c.expiryKey)
308	if err != nil {
309		return nil, fmt.Errorf("error parsing expiry-key %q from %q: %v", c.expiryKey, string(output), err)
310	}
311	var expiry time.Time
312	if t, err := time.Parse(c.timeFmt, expiryStr); err != nil {
313		glog.V(4).Infof("Failed to parse token expiry from %s (fmt=%s): %v", expiryStr, c.timeFmt, err)
314	} else {
315		expiry = t
316	}
317
318	return &oauth2.Token{
319		AccessToken: accessToken,
320		TokenType:   "Bearer",
321		Expiry:      expiry,
322	}, nil
323}
324
325func parseJSONPath(input interface{}, name, template string) (string, error) {
326	j := jsonpath.New(name)
327	buf := new(bytes.Buffer)
328	if err := j.Parse(template); err != nil {
329		return "", err
330	}
331	if err := j.Execute(buf, input); err != nil {
332		return "", err
333	}
334	return buf.String(), nil
335}
336
337type conditionalTransport struct {
338	oauthTransport *oauth2.Transport
339	persister      restclient.AuthProviderConfigPersister
340}
341
342var _ net.RoundTripperWrapper = &conditionalTransport{}
343
344func (t *conditionalTransport) RoundTrip(req *http.Request) (*http.Response, error) {
345	if len(req.Header.Get("Authorization")) != 0 {
346		return t.oauthTransport.Base.RoundTrip(req)
347	}
348
349	res, err := t.oauthTransport.RoundTrip(req)
350
351	if err != nil {
352		return nil, err
353	}
354
355	if res.StatusCode == 401 {
356		glog.V(4).Infof("The credentials that were supplied are invalid for the target cluster")
357		emptyCache := make(map[string]string)
358		t.persister.Persist(emptyCache)
359	}
360
361	return res, nil
362}
363
364func (t *conditionalTransport) WrappedRoundTripper() http.RoundTripper { return t.oauthTransport.Base }
365