diff --git a/google/sdk.go b/google/sdk.go index 456224bc7..0af04c601 100644 --- a/google/sdk.go +++ b/google/sdk.go @@ -5,44 +5,37 @@ package google import ( - "bufio" "context" "encoding/json" - "errors" "fmt" - "io" "net/http" "os" + "os/exec" "os/user" - "path/filepath" - "runtime" "strings" "time" "golang.org/x/oauth2" ) -type sdkCredentials struct { - Data []struct { - Credential struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenExpiry *time.Time `json:"token_expiry"` - } `json:"credential"` - Key struct { - Account string `json:"account"` - Scope string `json:"scope"` - } `json:"key"` - } +// configHelperResp corresponds to the JSON output of the `gcloud config-helper` command. +type configHelperResp struct { + Credential struct { + AccessToken string `json:"access_token"` + TokenExpiry string `json:"token_expiry"` + } `json:"credential"` } // An SDKConfig provides access to tokens from an account already // authorized via the Google Cloud SDK. type SDKConfig struct { - conf oauth2.Config - initialToken *oauth2.Token + // account is the name of the gcloud-authenticated account whose credentials should be used + // to generate OAuth tokens. This should be one of the accounts listed in the output of + // `gcloud auth list`. + // + // For instance, if the user logged in to gcloud with the account `user@example.com`, then + // they could use `user@example.com` as the value for this field. + account string } // NewSDKConfig creates an SDKConfig for the given Google Cloud SDK @@ -52,72 +45,16 @@ type SDKConfig struct { // before using this function. // The Google Cloud SDK is available at https://cloud.google.com/sdk/. func NewSDKConfig(account string) (*SDKConfig, error) { - configPath, err := sdkConfigPath() - if err != nil { - return nil, fmt.Errorf("oauth2/google: error getting SDK config path: %v", err) + if account != "" { + return &SDKConfig{account}, nil } - credentialsPath := filepath.Join(configPath, "credentials") - f, err := os.Open(credentialsPath) + cmd := exec.Command("gcloud", "auth", "list", "--filter=status=ACTIVE", "--format=value(account)") + out, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("oauth2/google: failed to load SDK credentials: %v", err) - } - defer f.Close() - - var c sdkCredentials - if err := json.NewDecoder(f).Decode(&c); err != nil { - return nil, fmt.Errorf("oauth2/google: failed to decode SDK credentials from %q: %v", credentialsPath, err) - } - if len(c.Data) == 0 { - return nil, fmt.Errorf("oauth2/google: no credentials found in %q, run `gcloud auth login` to create one", credentialsPath) - } - if account == "" { - propertiesPath := filepath.Join(configPath, "properties") - f, err := os.Open(propertiesPath) - if err != nil { - return nil, fmt.Errorf("oauth2/google: failed to load SDK properties: %v", err) - } - defer f.Close() - ini, err := parseINI(f) - if err != nil { - return nil, fmt.Errorf("oauth2/google: failed to parse SDK properties %q: %v", propertiesPath, err) - } - core, ok := ini["core"] - if !ok { - return nil, fmt.Errorf("oauth2/google: failed to find [core] section in %v", ini) - } - active, ok := core["account"] - if !ok { - return nil, fmt.Errorf("oauth2/google: failed to find %q attribute in %v", "account", core) - } - account = active - } - - for _, d := range c.Data { - if account == "" || d.Key.Account == account { - if d.Credential.AccessToken == "" && d.Credential.RefreshToken == "" { - return nil, fmt.Errorf("oauth2/google: no token available for account %q", account) - } - var expiry time.Time - if d.Credential.TokenExpiry != nil { - expiry = *d.Credential.TokenExpiry - } - return &SDKConfig{ - conf: oauth2.Config{ - ClientID: d.Credential.ClientID, - ClientSecret: d.Credential.ClientSecret, - Scopes: strings.Split(d.Key.Scope, " "), - Endpoint: Endpoint, - RedirectURL: "oob", - }, - initialToken: &oauth2.Token{ - AccessToken: d.Credential.AccessToken, - RefreshToken: d.Credential.RefreshToken, - Expiry: expiry, - }, - }, nil - } + return nil, fmt.Errorf("looking up the active Cloud SDK account: %v", err) } - return nil, fmt.Errorf("oauth2/google: no such credentials for account %q", account) + account = strings.TrimSpace(string(out)) + return &SDKConfig{account}, nil } // Client returns an HTTP client using Google Cloud SDK credentials to @@ -128,7 +65,7 @@ func NewSDKConfig(account string) (*SDKConfig, error) { func (c *SDKConfig) Client(ctx context.Context) *http.Client { return &http.Client{ Transport: &oauth2.Transport{ - Source: c.TokenSource(ctx), + Source: c, }, } } @@ -139,53 +76,33 @@ func (c *SDKConfig) Client(ctx context.Context) *http.Client { // and refresh it when it expires, but it won't update the credentials // with the new access token. func (c *SDKConfig) TokenSource(ctx context.Context) oauth2.TokenSource { - return c.conf.TokenSource(ctx, c.initialToken) -} - -// Scopes are the OAuth 2.0 scopes the current account is authorized for. -func (c *SDKConfig) Scopes() []string { - return c.conf.Scopes + return c } -func parseINI(ini io.Reader) (map[string]map[string]string, error) { - result := map[string]map[string]string{ - "": {}, // root section - } - scanner := bufio.NewScanner(ini) - currentSection := "" - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, ";") { - // comment. - continue - } - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - currentSection = strings.TrimSpace(line[1 : len(line)-1]) - result[currentSection] = map[string]string{} - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 && parts[0] != "" { - result[currentSection][strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } +func parseConfigHelperResp(b []byte) (*oauth2.Token, error) { + var r configHelperResp + if err := json.Unmarshal(b, &r); err != nil { + return nil, fmt.Errorf("parsing the config-helper output: %v", err) } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error scanning ini: %v", err) + expiryStr := r.Credential.TokenExpiry + expiry, err := time.Parse(time.RFC3339, expiryStr) + if err != nil { + return nil, fmt.Errorf("parsing the access token expiry time: %v", err) } - return result, nil + return &oauth2.Token{ + AccessToken: r.Credential.AccessToken, + Expiry: expiry, + }, nil } -// sdkConfigPath tries to guess where the gcloud config is located. -// It can be overridden during tests. -var sdkConfigPath = func() (string, error) { - if runtime.GOOS == "windows" { - return filepath.Join(os.Getenv("APPDATA"), "gcloud"), nil - } - homeDir := guessUnixHomeDir() - if homeDir == "" { - return "", errors.New("unable to get current user home directory: os/user lookup failed; $HOME is empty") +// Token returns an oauth2.Token retrieved from the Google Cloud SDK. +func (c *SDKConfig) Token() (*oauth2.Token, error) { + cmd := exec.Command("gcloud", "config", "config-helper", "--account", c.account, "--format=json") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running the config-helper command: %v", err) } - return filepath.Join(homeDir, ".config", "gcloud"), nil + return parseConfigHelperResp(out) } func guessUnixHomeDir() string { diff --git a/google/sdk_test.go b/google/sdk_test.go index 52b8ecada..b45500ccb 100644 --- a/google/sdk_test.go +++ b/google/sdk_test.go @@ -5,103 +5,42 @@ package google import ( - "reflect" - "strings" + "fmt" "testing" + "time" ) -func TestSDKConfig(t *testing.T) { - sdkConfigPath = func() (string, error) { - return "testdata/gcloud", nil - } +const mockResponseTemplate = `{ + "credential": { + "access_token": %q, + "token_expiry": %q + } +}` - tests := []struct { - account string - accessToken string - err bool - }{ - {"", "bar_access_token", false}, - {"foo@example.com", "foo_access_token", false}, - {"bar@example.com", "bar_access_token", false}, - {"baz@serviceaccount.example.com", "", true}, +func TestSDKConfig(t *testing.T) { + var helperCallCount int + mockTokenFormat := "Token #%d" + mockHelper := func() []byte { + token := fmt.Sprintf(mockTokenFormat, helperCallCount) + helperCallCount += 1 + return []byte(fmt.Sprintf(mockResponseTemplate, token, time.Now().Format(time.RFC3339))) } - for _, tt := range tests { - c, err := NewSDKConfig(tt.account) - if got, want := err != nil, tt.err; got != want { - if !tt.err { - t.Errorf("got %v, want nil", err) - } else { - t.Errorf("got nil, want error") - } - continue - } + for i := 0; i < 10; i++ { + tok, err := parseConfigHelperResp(mockHelper()) if err != nil { - continue - } - tok := c.initialToken - if tok == nil { - t.Errorf("got nil, want %q", tt.accessToken) - continue - } - if tok.AccessToken != tt.accessToken { - t.Errorf("got %q, want %q", tok.AccessToken, tt.accessToken) + t.Errorf("Unexpected error parsing a mock config helper response: %v", err) + } else if got, want := tok.AccessToken, fmt.Sprintf(mockTokenFormat, i); got != want { + t.Errorf("Got access token of %q; wanted %q", got, want) } } -} -func TestParseINI(t *testing.T) { - tests := []struct { - ini string - want map[string]map[string]string - }{ - { - `root = toor -[foo] -bar = hop -ini = nin -`, - map[string]map[string]string{ - "": {"root": "toor"}, - "foo": {"bar": "hop", "ini": "nin"}, - }, - }, - { - "\t extra \t = whitespace \t\r\n \t [everywhere] \t \r\n here \t = \t there \t \r\n", - map[string]map[string]string{ - "": {"extra": "whitespace"}, - "everywhere": {"here": "there"}, - }, - }, - { - `[empty] -[section] -empty= -`, - map[string]map[string]string{ - "": {}, - "empty": {}, - "section": {"empty": ""}, - }, - }, - { - `ignore -[invalid -=stuff -;comment=true -`, - map[string]map[string]string{ - "": {}, - }, - }, + badJSON := []byte(`Not really a JSON response`) + if tok, err := parseConfigHelperResp(badJSON); err == nil { + t.Errorf("unexpected parsing result for an malformed helper response: got %v", tok) } - for _, tt := range tests { - result, err := parseINI(strings.NewReader(tt.ini)) - if err != nil { - t.Errorf("parseINI(%q) error %v, want: no error", tt.ini, err) - continue - } - if !reflect.DeepEqual(result, tt.want) { - t.Errorf("parseINI(%q) = %#v, want: %#v", tt.ini, result, tt.want) - } + + badTimestamp := []byte(fmt.Sprintf(mockResponseTemplate, "Fake Token", "The time at which it expires")) + if tok, err := parseConfigHelperResp(badTimestamp); err == nil { + t.Errorf("unexpected parsing result for a helper response with a bad expiry timestamp: got %v", tok) } }