Skip to content

Commit

Permalink
make SDKConfig work with newer versions of gcloud
Browse files Browse the repository at this point in the history
The existing implementation of SDKConfig assumes that the Cloud SDK
writes a config file named `credentials`, which contains JSON-encoded
OAuth2 credentials for the user.

Since version 161.0.0 of the Cloud SDK, that has not been true, as the
Cloud SDK has switched to storing credentials in a SQLite database
instead of a JSON-encoded file.

This change switches from trying to directly read the underlying
credential store (which it is now clear can change without notice), to
instead invoking the `gcloud` command and asking it for its
credentials. This is done using the subcommand

```sh
gcloud config config-helper
```

That subcommand is meant to provide auth data to external tools, so it
seems to be a more appropriate choice than reading the underlying
storage directly.

This fixes golang#300
  • Loading branch information
ojarjur committed Jul 2, 2018
1 parent ef14785 commit 05c9441
Show file tree
Hide file tree
Showing 2 changed files with 90 additions and 210 deletions.
171 changes: 48 additions & 123 deletions google/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
package google

import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
Expand All @@ -22,27 +20,19 @@ import (
"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"`
}
type configHelperResp struct {
Credential struct {
AccessToken string `json:"access_token"`
TokenExpiry string `json:"token_expiry"`
} `json:"credential"`
}

type configHelper func() (*configHelperResp, error)

// 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
helper configHelper
}

// NewSDKConfig creates an SDKConfig for the given Google Cloud SDK
Expand All @@ -52,72 +42,37 @@ 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)
}
credentialsPath := filepath.Join(configPath, "credentials")
f, err := os.Open(credentialsPath)
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)
}
gcloudCmd := gcloudCommand()
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)
cmd := exec.Command(gcloudCmd, "auth", "list", "--filter=status=ACTIVE", "--format=value(account)")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failure looking up the active Cloud SDK account: %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)
account = strings.TrimSpace(out.String())
}
helper := func() (*configHelperResp, error) {
cmd := exec.Command(gcloudCmd, "config", "config-helper", "--account", account, "--format=json")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
active, ok := core["account"]
if !ok {
return nil, fmt.Errorf("oauth2/google: failed to find %q attribute in %v", "account", core)
var resp configHelperResp
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("failure parsing the output from the Cloud SDK config helper: %v", err)
}
account = active
return &resp, nil
}
return &SDKConfig{helper}, nil
}

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
}
func gcloudCommand() string {
if runtime.GOOS == "windows" {
return "gcloud.cmd"
}
return nil, fmt.Errorf("oauth2/google: no such credentials for account %q", account)
return "gcloud"
}

// Client returns an HTTP client using Google Cloud SDK credentials to
Expand All @@ -128,7 +83,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,
},
}
}
Expand All @@ -139,53 +94,23 @@ 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)
return c
}

// Scopes are the OAuth 2.0 scopes the current account is authorized for.
func (c *SDKConfig) Scopes() []string {
return c.conf.Scopes
}

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])
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error scanning ini: %v", err)
}
return result, 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
// Token returns an oauth2.Token retrieved from the Google Cloud SDK.
func (c *SDKConfig) Token() (*oauth2.Token, error) {
resp, err := c.helper()
if err != nil {
return nil, fmt.Errorf("failure invoking the Cloud SDK config helper: %v", err)
}
homeDir := guessUnixHomeDir()
if homeDir == "" {
return "", errors.New("unable to get current user home directory: os/user lookup failed; $HOME is empty")
expiry, err := time.Parse(time.RFC3339, resp.Credential.TokenExpiry)
if err != nil {
return nil, fmt.Errorf("failure parsing the access token expiration time: %v", err)
}
return filepath.Join(homeDir, ".config", "gcloud"), nil
return &oauth2.Token{
AccessToken: resp.Credential.AccessToken,
Expiry: expiry,
}, nil
}

func guessUnixHomeDir() string {
Expand Down
129 changes: 42 additions & 87 deletions google/sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,103 +5,58 @@
package google

import (
"reflect"
"strings"
"fmt"
"testing"
"time"
)

func TestSDKConfig(t *testing.T) {
sdkConfigPath = func() (string, error) {
return "testdata/gcloud", nil
}

tests := []struct {
account string
accessToken string
err bool
}{
{"", "bar_access_token", false},
{"[email protected]", "foo_access_token", false},
{"[email protected]", "bar_access_token", false},
{"[email protected]", "", true},
var helperCallCount int
mockTokenFormat := "Token #%d"
mockHelper := func() (*configHelperResp, error) {
token := fmt.Sprintf(mockTokenFormat, helperCallCount)
helperCallCount += 1
return &configHelperResp{
Credential: struct {
AccessToken string `json:"access_token"`
TokenExpiry string `json:"token_expiry"`
}{
AccessToken: token,
TokenExpiry: time.Now().Format(time.RFC3339),
},
}, nil
}
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
}
mockConfig := &SDKConfig{mockHelper}
for i := 0; i < 10; i++ {
tok, err := mockConfig.Token()
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 reading 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{
"": {},
failingHelper := func() (*configHelperResp, error) {
return nil, fmt.Errorf("mock config helper failure")
}
failingConfig := &SDKConfig{failingHelper}
if tok, err := failingConfig.Token(); err == nil {
t.Errorf("unexpected token response for failing helper: got %v", tok)
}

badTimestampHelper := func() (*configHelperResp, error) {
return &configHelperResp{
Credential: struct {
AccessToken string `json:"access_token"`
TokenExpiry string `json:"token_expiry"`
}{
AccessToken: "Fake token",
TokenExpiry: "The time at which it expires",
},
},
}, nil
}
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)
}
badTimestampConfig := &SDKConfig{badTimestampHelper}
if tok, err := badTimestampConfig.Token(); err == nil {
t.Errorf("unexpected token response for a helper that returns bad expiry timestamps: got %v", tok)
}
}

0 comments on commit 05c9441

Please sign in to comment.