google: base account credentials with file-sourcing
Implements the core functionality to allow 3rd party identities access to Google APIs. Specifically, this PR implements the base account credential type and supports file-sourced credentials such as Kubernetes workloads. Later updates will add support for URL-sourced credentials such as Microsoft Azure and support for AWS credentials.
Change-Id: I6e09a450f5221a1e06394b51374cff70ab3ab8a7
GitHub-Last-Rev: 3ab51622f8f7c6982a5e78ae9644675659318e7b
GitHub-Pull-Request: golang/oauth2#462
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/276312
Reviewed-by: Tyler Bui-Palsulich <tbp@google.com>
Trust: Tyler Bui-Palsulich <tbp@google.com>
Trust: Cody Oss <codyoss@google.com>
Run-TryBot: Tyler Bui-Palsulich <tbp@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/google/google.go b/google/google.go
index 81de32b..e247491 100644
--- a/google/google.go
+++ b/google/google.go
@@ -15,6 +15,7 @@
"cloud.google.com/go/compute/metadata"
"golang.org/x/oauth2"
+ "golang.org/x/oauth2/google/internal/externalaccount"
"golang.org/x/oauth2/jwt"
)
@@ -93,6 +94,7 @@
const (
serviceAccountKey = "service_account"
userCredentialsKey = "authorized_user"
+ externalAccountKey = "external_account"
)
// credentialsFile is the unmarshalled representation of a credentials file.
@@ -111,6 +113,16 @@
ClientSecret string `json:"client_secret"`
ClientID string `json:"client_id"`
RefreshToken string `json:"refresh_token"`
+
+ // External Account fields
+ Audience string `json:"audience"`
+ SubjectTokenType string `json:"subject_token_type"`
+ TokenURLExternal string `json:"token_url"`
+ TokenInfoURL string `json:"token_info_url"`
+ ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
+ CredentialSource externalaccount.CredentialSource `json:"credential_source"`
+ QuotaProjectID string `json:"quota_project_id"`
+
}
func (f *credentialsFile) jwtConfig(scopes []string) *jwt.Config {
@@ -141,6 +153,20 @@
}
tok := &oauth2.Token{RefreshToken: f.RefreshToken}
return cfg.TokenSource(ctx, tok), nil
+ case externalAccountKey:
+ cfg := &externalaccount.Config{
+ Audience: f.Audience,
+ SubjectTokenType: f.SubjectTokenType,
+ TokenURL: f.TokenURLExternal,
+ TokenInfoURL: f.TokenInfoURL,
+ ServiceAccountImpersonationURL: f.ServiceAccountImpersonationURL,
+ ClientSecret: f.ClientSecret,
+ ClientID: f.ClientID,
+ CredentialSource: f.CredentialSource,
+ QuotaProjectID: f.QuotaProjectID,
+ Scopes: scopes,
+ }
+ return cfg.TokenSource(ctx), nil
case "":
return nil, errors.New("missing 'type' field in credentials")
default:
diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go
new file mode 100644
index 0000000..3291d46
--- /dev/null
+++ b/google/internal/externalaccount/basecredentials.go
@@ -0,0 +1,133 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+ "context"
+ "fmt"
+ "golang.org/x/oauth2"
+ "net/http"
+ "time"
+)
+
+// now aliases time.Now for testing
+var now = time.Now
+
+// Config stores the configuration for fetching tokens with external credentials.
+type Config struct {
+ Audience string
+ SubjectTokenType string
+ TokenURL string
+ TokenInfoURL string
+ ServiceAccountImpersonationURL string
+ ClientSecret string
+ ClientID string
+ CredentialSource CredentialSource
+ QuotaProjectID string
+ Scopes []string
+}
+
+// TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials.
+func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
+ ts := tokenSource{
+ ctx: ctx,
+ conf: c,
+ }
+ return oauth2.ReuseTokenSource(nil, ts)
+}
+
+// Subject token file types.
+const (
+ fileTypeText = "text"
+ fileTypeJSON = "json"
+)
+
+type format struct {
+ // Type is either "text" or "json". When not provided "text" type is assumed.
+ Type string `json:"type"`
+ // SubjectTokenFieldName is only required for JSON format. This would be "access_token" for azure.
+ SubjectTokenFieldName string `json:"subject_token_field_name"`
+}
+
+// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange.
+type CredentialSource struct {
+ File string `json:"file"`
+
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers"`
+
+ EnvironmentID string `json:"environment_id"`
+ RegionURL string `json:"region_url"`
+ RegionalCredVerificationURL string `json:"regional_cred_verification_url"`
+ CredVerificationURL string `json:"cred_verification_url"`
+ Format format `json:"format"`
+}
+
+// parse determines the type of CredentialSource needed
+func (c *Config) parse() baseCredentialSource {
+ if c.CredentialSource.File != "" {
+ return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}
+ }
+ return nil
+}
+
+type baseCredentialSource interface {
+ subjectToken() (string, error)
+}
+
+// tokenSource is the source that handles external credentials.
+type tokenSource struct {
+ ctx context.Context
+ conf *Config
+}
+
+// Token allows tokenSource to conform to the oauth2.TokenSource interface.
+func (ts tokenSource) Token() (*oauth2.Token, error) {
+ conf := ts.conf
+
+ credSource := conf.parse()
+ if credSource == nil {
+ return nil, fmt.Errorf("oauth2/google: unable to parse credential source")
+ }
+ subjectToken, err := credSource.subjectToken()
+ if err != nil {
+ return nil, err
+ }
+ stsRequest := STSTokenExchangeRequest{
+ GrantType: "urn:ietf:params:oauth:grant-type:token-exchange",
+ Audience: conf.Audience,
+ Scope: conf.Scopes,
+ RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
+ SubjectToken: subjectToken,
+ SubjectTokenType: conf.SubjectTokenType,
+ }
+ header := make(http.Header)
+ header.Add("Content-Type", "application/x-www-form-urlencoded")
+ clientAuth := ClientAuthentication{
+ AuthStyle: oauth2.AuthStyleInHeader,
+ ClientID: conf.ClientID,
+ ClientSecret: conf.ClientSecret,
+ }
+ stsResp, err := ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ accessToken := &oauth2.Token{
+ AccessToken: stsResp.AccessToken,
+ TokenType: stsResp.TokenType,
+ }
+ if stsResp.ExpiresIn < 0 {
+ return nil, fmt.Errorf("oauth2/google: got invalid expiry from security token service")
+ } else if stsResp.ExpiresIn >= 0 {
+ accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second)
+ }
+
+ if stsResp.RefreshToken != "" {
+ accessToken.RefreshToken = stsResp.RefreshToken
+ }
+
+ return accessToken, nil
+}
diff --git a/google/internal/externalaccount/basecredentials_test.go b/google/internal/externalaccount/basecredentials_test.go
new file mode 100644
index 0000000..7ec12e4
--- /dev/null
+++ b/google/internal/externalaccount/basecredentials_test.go
@@ -0,0 +1,93 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+ "context"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+var testBaseCredSource = CredentialSource{
+ File: "./testdata/3pi_cred.txt",
+ Format: format{Type: fileTypeText},
+}
+
+var testConfig = Config{
+ Audience: "32555940559.apps.googleusercontent.com",
+ SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
+ TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
+ ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
+ ClientSecret: "notsosecret",
+ ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
+ CredentialSource: testBaseCredSource,
+ Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
+}
+
+var (
+ baseCredsRequestBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=null&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
+ baseCredsResponseBody = `{"access_token":"Sample.Access.Token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600,"scope":"https://www.googleapis.com/auth/cloud-platform"}`
+ correctAT = "Sample.Access.Token"
+ expiry int64 = 234852
+)
+var (
+ testNow = func() time.Time { return time.Unix(expiry, 0) }
+)
+
+func TestToken(t *testing.T) {
+
+ targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got, want := r.URL.String(), "/"; got != want {
+ t.Errorf("URL.String(): got %v but want %v", got, want)
+ }
+ headerAuth := r.Header.Get("Authorization")
+ if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
+ t.Errorf("got %v but want %v", got, want)
+ }
+ headerContentType := r.Header.Get("Content-Type")
+ if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
+ t.Errorf("got %v but want %v", got, want)
+ }
+ body, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("Failed reading request body: %s.", err)
+ }
+ if got, want := string(body), baseCredsRequestBody; got != want {
+ t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(baseCredsResponseBody))
+ }))
+ defer targetServer.Close()
+
+ testConfig.TokenURL = targetServer.URL
+ ourTS := tokenSource{
+ ctx: context.Background(),
+ conf: &testConfig,
+ }
+
+ oldNow := now
+ defer func() { now = oldNow }()
+ now = testNow
+
+ tok, err := ourTS.Token()
+ if err != nil {
+ t.Fatalf("Unexpected error: %e", err)
+ }
+ if got, want := tok.AccessToken, correctAT; got != want {
+ t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
+ }
+ if got, want := tok.TokenType, "Bearer"; got != want {
+ t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
+ }
+
+ if got, want := tok.Expiry, now().Add(time.Duration(3600)*time.Second); got != want {
+ t.Errorf("Unexpected Expiry: got %v, but wanted %v", got, want)
+ }
+
+}
diff --git a/google/internal/externalaccount/filecredsource.go b/google/internal/externalaccount/filecredsource.go
new file mode 100644
index 0000000..e953ddb
--- /dev/null
+++ b/google/internal/externalaccount/filecredsource.go
@@ -0,0 +1,57 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+)
+
+type fileCredentialSource struct {
+ File string
+ Format format
+}
+
+func (cs fileCredentialSource) subjectToken() (string, error) {
+ tokenFile, err := os.Open(cs.File)
+ if err != nil {
+ return "", fmt.Errorf("oauth2/google: failed to open credential file %q", cs.File)
+ }
+ defer tokenFile.Close()
+ tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20))
+ if err != nil {
+ return "", fmt.Errorf("oauth2/google: failed to read credential file: %v", err)
+ }
+ tokenBytes = bytes.TrimSpace(tokenBytes)
+ switch cs.Format.Type {
+ case "json":
+ jsonData := make(map[string]interface{})
+ err = json.Unmarshal(tokenBytes, &jsonData)
+ if err != nil {
+ return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err)
+ }
+ val, ok := jsonData[cs.Format.SubjectTokenFieldName]
+ if !ok {
+ return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials")
+ }
+ token, ok := val.(string)
+ if !ok {
+ return "", errors.New("oauth2/google: improperly formatted subject token")
+ }
+ return token, nil
+ case "text":
+ return string(tokenBytes), nil
+ case "":
+ return string(tokenBytes), nil
+ default:
+ return "", errors.New("oauth2/google: invalid credential_source file format type")
+ }
+
+}
diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/internal/externalaccount/filecredsource_test.go
new file mode 100644
index 0000000..0bc8048
--- /dev/null
+++ b/google/internal/externalaccount/filecredsource_test.go
@@ -0,0 +1,67 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+ "testing"
+)
+
+var testFileConfig = Config{
+ Audience: "32555940559.apps.googleusercontent.com",
+ SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
+ TokenURL: "http://localhost:8080/v1/token",
+ TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
+ ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
+ ClientSecret: "notsosecret",
+ ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
+}
+
+func TestRetrieveFileSubjectToken(t *testing.T) {
+ var fileSourceTests = []struct {
+ name string
+ cs CredentialSource
+ want string
+ }{
+ {
+ name: "UntypedFileSource",
+ cs: CredentialSource{
+ File: "./testdata/3pi_cred.txt",
+ },
+ want: "street123",
+ },
+ {
+ name: "TextFileSource",
+ cs: CredentialSource{
+ File: "./testdata/3pi_cred.txt",
+ Format: format{Type: fileTypeText},
+ },
+ want: "street123",
+ },
+ {
+ name: "JSONFileSource",
+ cs: CredentialSource{
+ File: "./testdata/3pi_cred.json",
+ Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"},
+ },
+ want: "321road",
+ },
+ }
+
+ for _, test := range fileSourceTests {
+ test := test
+ tfc := testFileConfig
+ tfc.CredentialSource = test.cs
+
+ t.Run(test.name, func(t *testing.T) {
+ out, err := tfc.parse().subjectToken()
+ if err != nil {
+ t.Errorf("Method subjectToken() errored.")
+ } else if test.want != out {
+ t.Errorf("got %v but want %v", out, test.want)
+ }
+
+ })
+ }
+}
diff --git a/google/internal/externalaccount/testdata/3pi_cred.json b/google/internal/externalaccount/testdata/3pi_cred.json
new file mode 100644
index 0000000..6a9cf7d
--- /dev/null
+++ b/google/internal/externalaccount/testdata/3pi_cred.json
@@ -0,0 +1,3 @@
+{
+ "SubjToken": "321road"
+}
diff --git a/google/internal/externalaccount/testdata/3pi_cred.txt b/google/internal/externalaccount/testdata/3pi_cred.txt
new file mode 100644
index 0000000..4e511cc
--- /dev/null
+++ b/google/internal/externalaccount/testdata/3pi_cred.txt
@@ -0,0 +1 @@
+street123