Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

google: make SDKConfig work with newer versions of gcloud #301

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 42 additions & 125 deletions google/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `[email protected]`, then
// they could use `[email protected]` as the value for this field.
account string
}

// NewSDKConfig creates an SDKConfig for the given Google Cloud SDK
Expand All @@ -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
Expand All @@ -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,
},
}
}
Expand All @@ -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 {
Expand Down
115 changes: 27 additions & 88 deletions google/sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
{"[email protected]", "foo_access_token", false},
{"[email protected]", "bar_access_token", false},
{"[email protected]", "", 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)
}
}