From 931764155e3f087a554e325f3e6a3adc3fdf4a03 Mon Sep 17 00:00:00 2001 From: Julie Qiu Date: Thu, 3 Dec 2020 18:41:36 -0500 Subject: [PATCH 1/8] README.md: add badge to pkg.go.dev Change-Id: I90a3334507f4501ee082afeb878b82f71b3392ae Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/275303 Trust: Julie Qiu Reviewed-by: Andrew Gerrand --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8cfd606..58f4246 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OAuth2 for Go [![Build Status](https://travis-ci.org/golang/oauth2.svg?branch=master)](https://travis-ci.org/golang/oauth2) -[![GoDoc](https://godoc.org/golang.org/x/oauth2?status.svg)](https://godoc.org/golang.org/x/oauth2) +[![Go Package Documentation](https://pkg.go.dev/badge/golang.org/x/oauth2.svg)](https://pkg.go.dev/golang.org/x/oauth2) oauth2 package contains a client implementation for OAuth 2.0 spec. From 08078c50e5b5244ec123a6b69facdbc13b21a548 Mon Sep 17 00:00:00 2001 From: Julie Qiu Date: Mon, 7 Dec 2020 22:44:01 -0500 Subject: [PATCH 2/8] README.md: change godoc.org links to pkg.go.dev Links to godoc.org are changed to pkg.go.dev. The README badge alt text is changed to "Go Reference" to match the updated alt text generated by pkg.go.dev/badge. Change-Id: I935cbe03477131a4361a8ac7b5ba9fd3e378cbdd Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/276016 Reviewed-by: Dmitri Shuralyov Reviewed-by: Andrew Gerrand Trust: Julie Qiu --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 58f4246..1473e12 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OAuth2 for Go +[![Go Reference](https://pkg.go.dev/badge/golang.org/x/oauth2.svg)](https://pkg.go.dev/golang.org/x/oauth2) [![Build Status](https://travis-ci.org/golang/oauth2.svg?branch=master)](https://travis-ci.org/golang/oauth2) -[![Go Package Documentation](https://pkg.go.dev/badge/golang.org/x/oauth2.svg)](https://pkg.go.dev/golang.org/x/oauth2) oauth2 package contains a client implementation for OAuth 2.0 spec. @@ -14,17 +14,17 @@ go get golang.org/x/oauth2 Or you can manually git clone the repository to `$(go env GOPATH)/src/golang.org/x/oauth2`. -See godoc for further documentation and examples. +See pkg.go.dev for further documentation and examples. -* [godoc.org/golang.org/x/oauth2](https://godoc.org/golang.org/x/oauth2) -* [godoc.org/golang.org/x/oauth2/google](https://godoc.org/golang.org/x/oauth2/google) +* [pkg.go.dev/golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) +* [pkg.go.dev/golang.org/x/oauth2/google](https://pkg.go.dev/golang.org/x/oauth2/google) ## Policy for new packages We no longer accept new provider-specific packages in this repo if all they do is add a single endpoint variable. If you just want to add a single endpoint, add it to the -[godoc.org/golang.org/x/oauth2/endpoints](https://godoc.org/golang.org/x/oauth2/endpoints) +[pkg.go.dev/golang.org/x/oauth2/endpoints](https://pkg.go.dev/golang.org/x/oauth2/endpoints) package. ## Report Issues / Send Patches From 01de73cf58bdca33ccc181d1bd6d63ebcf21ccca Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Tue, 12 Jan 2021 19:45:47 +0000 Subject: [PATCH 3/8] 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 Trust: Tyler Bui-Palsulich Trust: Cody Oss Run-TryBot: Tyler Bui-Palsulich TryBot-Result: Go Bot --- google/google.go | 26 ++++ .../externalaccount/basecredentials.go | 133 ++++++++++++++++++ .../externalaccount/basecredentials_test.go | 93 ++++++++++++ .../externalaccount/filecredsource.go | 57 ++++++++ .../externalaccount/filecredsource_test.go | 67 +++++++++ .../externalaccount/testdata/3pi_cred.json | 3 + .../externalaccount/testdata/3pi_cred.txt | 1 + 7 files changed, 380 insertions(+) create mode 100644 google/internal/externalaccount/basecredentials.go create mode 100644 google/internal/externalaccount/basecredentials_test.go create mode 100644 google/internal/externalaccount/filecredsource.go create mode 100644 google/internal/externalaccount/filecredsource_test.go create mode 100644 google/internal/externalaccount/testdata/3pi_cred.json create mode 100644 google/internal/externalaccount/testdata/3pi_cred.txt 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 @@ import ( "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 @@ func JWTConfigFromJSON(jsonKey []byte, scope ...string) (*jwt.Config, error) { const ( serviceAccountKey = "service_account" userCredentialsKey = "authorized_user" + externalAccountKey = "external_account" ) // credentialsFile is the unmarshalled representation of a credentials file. @@ -111,6 +113,16 @@ type credentialsFile struct { 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 @@ func (f *credentialsFile) tokenSource(ctx context.Context, scopes []string) (oau } 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 From 8b1d76fa042330bf6fcfbda4a3da5f69d1b64f5f Mon Sep 17 00:00:00 2001 From: Cody Oss Date: Wed, 13 Jan 2021 08:23:33 -0700 Subject: [PATCH 4/8] google: restore 1.11 compatibility NewRequestWithContext requires 1.13. As this is just a convenience we should try to retatin the 1.11 compatibility by using NewRequest then calling WithContext instead. Change-Id: I6208a92061b208a119fdf04fd561a3e4d22bc547 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/283535 Reviewed-by: Tyler Bui-Palsulich Trust: Tyler Bui-Palsulich Trust: Cody Oss Run-TryBot: Tyler Bui-Palsulich TryBot-Result: Go Bot --- google/internal/externalaccount/sts_exchange.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/google/internal/externalaccount/sts_exchange.go b/google/internal/externalaccount/sts_exchange.go index d7f54e0..c7d85a3 100644 --- a/google/internal/externalaccount/sts_exchange.go +++ b/google/internal/externalaccount/sts_exchange.go @@ -8,12 +8,13 @@ import ( "context" "encoding/json" "fmt" - "golang.org/x/oauth2" "io" "net/http" "net/url" "strconv" "strings" + + "golang.org/x/oauth2" ) // ExchangeToken performs an oauth2 token exchange with the provided endpoint. @@ -40,11 +41,12 @@ func ExchangeToken(ctx context.Context, endpoint string, request *STSTokenExchan authentication.InjectAuthentication(data, headers) encodedData := data.Encode() - req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(encodedData)) + req, err := http.NewRequest("POST", endpoint, strings.NewReader(encodedData)) if err != nil { return nil, fmt.Errorf("oauth2/google: failed to properly build http request: %v", err) } + req = req.WithContext(ctx) for key, list := range headers { for _, val := range list { req.Header.Add(key, val) From d3ed898aa8a312e7e3290a8ac5a123149de68011 Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Wed, 13 Jan 2021 20:38:24 +0000 Subject: [PATCH 5/8] google: support url-sourced 3rd party credentials Implements functionality to allow for URL-sourced 3rd party credentials, expanding the functionality added in #462 . Change-Id: Ib7615fb618486612960d60bee6b9a1ecf5de1404 GitHub-Last-Rev: 95713928e495d51d2209bb81cbf2c16185441145 GitHub-Pull-Request: golang/oauth2#466 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/283372 Run-TryBot: Cody Oss TryBot-Result: Go Bot Reviewed-by: Cody Oss Trust: Tyler Bui-Palsulich Trust: Cody Oss --- google/google.go | 33 ++++--- .../externalaccount/basecredentials.go | 6 +- .../externalaccount/filecredsource_test.go | 3 +- .../internal/externalaccount/urlcredsource.go | 71 ++++++++++++++ .../externalaccount/urlcredsource_test.go | 92 +++++++++++++++++++ 5 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 google/internal/externalaccount/urlcredsource.go create mode 100644 google/internal/externalaccount/urlcredsource_test.go diff --git a/google/google.go b/google/google.go index e247491..2c8f1bd 100644 --- a/google/google.go +++ b/google/google.go @@ -115,14 +115,13 @@ type credentialsFile struct { 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"` - + 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 { @@ -155,16 +154,16 @@ func (f *credentialsFile) tokenSource(ctx context.Context, scopes []string) (oau return cfg.TokenSource(ctx, tok), nil case externalAccountKey: cfg := &externalaccount.Config{ - Audience: f.Audience, - SubjectTokenType: f.SubjectTokenType, - TokenURL: f.TokenURLExternal, - TokenInfoURL: f.TokenInfoURL, + 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, + ClientSecret: f.ClientSecret, + ClientID: f.ClientID, + CredentialSource: f.CredentialSource, + QuotaProjectID: f.QuotaProjectID, + Scopes: scopes, } return cfg.TokenSource(ctx), nil case "": diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go index 3291d46..dff0881 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -66,9 +66,11 @@ type CredentialSource struct { } // parse determines the type of CredentialSource needed -func (c *Config) parse() baseCredentialSource { +func (c *Config) parse(ctx context.Context) baseCredentialSource { if c.CredentialSource.File != "" { return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format} + } else if c.CredentialSource.URL != "" { + return urlCredentialSource{URL: c.CredentialSource.URL, Format: c.CredentialSource.Format, ctx: ctx} } return nil } @@ -87,7 +89,7 @@ type tokenSource struct { func (ts tokenSource) Token() (*oauth2.Token, error) { conf := ts.conf - credSource := conf.parse() + credSource := conf.parse(ts.ctx) if credSource == nil { return nil, fmt.Errorf("oauth2/google: unable to parse credential source") } diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/internal/externalaccount/filecredsource_test.go index 0bc8048..56dd71e 100644 --- a/google/internal/externalaccount/filecredsource_test.go +++ b/google/internal/externalaccount/filecredsource_test.go @@ -5,6 +5,7 @@ package externalaccount import ( + "context" "testing" ) @@ -55,7 +56,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { tfc.CredentialSource = test.cs t.Run(test.name, func(t *testing.T) { - out, err := tfc.parse().subjectToken() + out, err := tfc.parse(context.Background()).subjectToken() if err != nil { t.Errorf("Method subjectToken() errored.") } else if test.want != out { diff --git a/google/internal/externalaccount/urlcredsource.go b/google/internal/externalaccount/urlcredsource.go new file mode 100644 index 0000000..b0d5d35 --- /dev/null +++ b/google/internal/externalaccount/urlcredsource.go @@ -0,0 +1,71 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "golang.org/x/oauth2" + "io" + "io/ioutil" + "net/http" +) + +type urlCredentialSource struct { + URL string + Headers map[string]string + Format format + ctx context.Context +} + +func (cs urlCredentialSource) subjectToken() (string, error) { + client := oauth2.NewClient(cs.ctx, nil) + req, err := http.NewRequest("GET", cs.URL, nil) + if err != nil { + return "", fmt.Errorf("oauth2/google: HTTP request for URL-sourced credential failed: %v", err) + } + req = req.WithContext(cs.ctx) + + for key, val := range cs.Headers { + req.Header.Add(key, val) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("oauth2/google: invalid response when retrieving subject token: %v", err) + } + defer resp.Body.Close() + + tokenBytes, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("oauth2/google: invalid body in subject token URL query: %v", err) + } + + 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/urlcredsource_test.go b/google/internal/externalaccount/urlcredsource_test.go new file mode 100644 index 0000000..592610f --- /dev/null +++ b/google/internal/externalaccount/urlcredsource_test.go @@ -0,0 +1,92 @@ +// 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" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +var myURLToken = "testTokenValue" + +func TestRetrieveURLSubjectToken_Text(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Unexpected request method, %v is found", r.Method) + } + w.Write([]byte("testTokenValue")) + })) + cs := CredentialSource{ + URL: ts.URL, + Format: format{Type: fileTypeText}, + } + tfc := testFileConfig + tfc.CredentialSource = cs + + out, err := tfc.parse(context.Background()).subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + if out != myURLToken { + t.Errorf("got %v but want %v", out, myURLToken) + } +} + +// Checking that retrieveSubjectToken properly defaults to type text +func TestRetrieveURLSubjectToken_Untyped(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Unexpected request method, %v is found", r.Method) + } + w.Write([]byte("testTokenValue")) + })) + cs := CredentialSource{ + URL: ts.URL, + } + tfc := testFileConfig + tfc.CredentialSource = cs + + out, err := tfc.parse(context.Background()).subjectToken() + if err != nil { + t.Fatalf("Failed to retrieve URL subject token: %v", err) + } + if out != myURLToken { + t.Errorf("got %v but want %v", out, myURLToken) + } +} + +func TestRetrieveURLSubjectToken_JSON(t *testing.T) { + type tokenResponse struct { + TestToken string `json:"SubjToken"` + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, "GET"; got != want { + t.Errorf("got %v, but want %v", r.Method, want) + } + resp := tokenResponse{TestToken: "testTokenValue"} + jsonResp, err := json.Marshal(resp) + if err != nil { + t.Errorf("Failed to marshal values: %v", err) + } + w.Write(jsonResp) + })) + cs := CredentialSource{ + URL: ts.URL, + Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + } + tfc := testFileConfig + tfc.CredentialSource = cs + + out, err := tfc.parse(context.Background()).subjectToken() + if err != nil { + t.Fatalf("%v", err) + } + if out != myURLToken { + t.Errorf("got %v but want %v", out, myURLToken) + } +} From 45380de018898b4a8ddd50146ebc8e62d42668e6 Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Tue, 19 Jan 2021 16:28:06 -0800 Subject: [PATCH 6/8] Supporting service account impersonation. Change-Id: I6481964659c2c852e50bf8b19a1306629e7cc4ae --- .../externalaccount/basecredentials.go | 9 +- .../externalaccount/basecredentials_test.go | 17 ++- .../internal/externalaccount/impersonate.go | 79 ++++++++++++++ .../externalaccount/impersonate_test.go | 100 ++++++++++++++++++ 4 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 google/internal/externalaccount/impersonate.go create mode 100644 google/internal/externalaccount/impersonate_test.go diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go index dff0881..56284c8 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -89,6 +89,14 @@ type tokenSource struct { func (ts tokenSource) Token() (*oauth2.Token, error) { conf := ts.conf + if conf.ServiceAccountImpersonationURL != "" { + token, err := ts.impersonate() + if err != nil { + return nil, err + } + return token, err + } + credSource := conf.parse(ts.ctx) if credSource == nil { return nil, fmt.Errorf("oauth2/google: unable to parse credential source") @@ -130,6 +138,5 @@ func (ts tokenSource) Token() (*oauth2.Token, error) { 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 index 7ec12e4..eb60899 100644 --- a/google/internal/externalaccount/basecredentials_test.go +++ b/google/internal/externalaccount/basecredentials_test.go @@ -19,14 +19,13 @@ var testBaseCredSource = CredentialSource{ } 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"}, + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, } var ( @@ -55,7 +54,7 @@ func TestToken(t *testing.T) { } body, err := ioutil.ReadAll(r.Body) if err != nil { - t.Errorf("Failed reading request body: %s.", err) + t.Fatalf("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) diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/externalaccount/impersonate.go new file mode 100644 index 0000000..4c22aac --- /dev/null +++ b/google/internal/externalaccount/impersonate.go @@ -0,0 +1,79 @@ +// 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" + "fmt" + "golang.org/x/oauth2" + "io" + "io/ioutil" + "net/http" + "time" +) + +// generateAccesstokenReq is used for service account impersonation +type generateAccessTokenReq struct { + Delegates []string `json:"delegates,omitempty"` + Lifetime string `json:"lifetime,omitempty"` + Scope []string `json:"scope,omitempty"` +} + +type impersonateTokenResponse struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` +} + +func (ts tokenSource) impersonate() (*oauth2.Token, error) { + reqBody := generateAccessTokenReq{ + Lifetime: "3600s", + Scope: ts.conf.Scopes, + } + b, err := json.Marshal(reqBody) + + serviceAccountImpersonationURL := ts.conf.ServiceAccountImpersonationURL + ts.conf.ServiceAccountImpersonationURL = "" + ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} + + client := oauth2.NewClient(ts.ctx, ts) + if err != nil { + return &oauth2.Token{}, fmt.Errorf("google: unable to marshal request: %v", err) + } + req, err := http.NewRequest("POST", serviceAccountImpersonationURL, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to create request: %v", err) + } + req = req.WithContext(ts.ctx) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var accessTokenResp impersonateTokenResponse + if err := json.Unmarshal(body, &accessTokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) + } + expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err) + } + return &oauth2.Token{ + AccessToken: accessTokenResp.AccessToken, + Expiry: expiry, + TokenType: "Bearer", + }, nil + +} diff --git a/google/internal/externalaccount/impersonate_test.go b/google/internal/externalaccount/impersonate_test.go new file mode 100644 index 0000000..302a175 --- /dev/null +++ b/google/internal/externalaccount/impersonate_test.go @@ -0,0 +1,100 @@ +// 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" +) + +var testImpersonateConfig = Config{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, +} + +var ( + baseImpersonateCredsReqBody = "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%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" + baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}` +) + +func TestImpersonation(t *testing.T) { + impersonateServer := 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, "Bearer Sample.Access.Token"; got != want { + t.Errorf("got %v but want %v", got, want) + } + headerContentType := r.Header.Get("Content-Type") + if got, want := headerContentType, "application/json"; got != want { + t.Errorf("got %v but want %v", got, want) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed reading request body: %v.", err) + } + if got, want := string(body), "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}"; got != want { + t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(baseImpersonateCredsRespBody)) + })) + testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL + 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.Fatalf("Failed reading request body: %v.", err) + } + if got, want := string(body), baseImpersonateCredsReqBody; 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() + + testImpersonateConfig.TokenURL = targetServer.URL + ourTS := tokenSource{ + ctx: context.Background(), + conf: &testImpersonateConfig, + } + + 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, "Second.Access.Token"; 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) + } + +} From 96035656b2b6ecf7f4b19f587d0e24c42cf4556c Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Tue, 19 Jan 2021 16:28:06 -0800 Subject: [PATCH 7/8] Supporting service account impersonation. Change-Id: I6481964659c2c852e50bf8b19a1306629e7cc4ae --- .../externalaccount/basecredentials.go | 9 +- .../externalaccount/basecredentials_test.go | 17 ++- .../internal/externalaccount/impersonate.go | 79 ++++++++++++++ .../externalaccount/impersonate_test.go | 100 ++++++++++++++++++ 4 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 google/internal/externalaccount/impersonate.go create mode 100644 google/internal/externalaccount/impersonate_test.go diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go index dff0881..56284c8 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -89,6 +89,14 @@ type tokenSource struct { func (ts tokenSource) Token() (*oauth2.Token, error) { conf := ts.conf + if conf.ServiceAccountImpersonationURL != "" { + token, err := ts.impersonate() + if err != nil { + return nil, err + } + return token, err + } + credSource := conf.parse(ts.ctx) if credSource == nil { return nil, fmt.Errorf("oauth2/google: unable to parse credential source") @@ -130,6 +138,5 @@ func (ts tokenSource) Token() (*oauth2.Token, error) { 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 index 7ec12e4..eb60899 100644 --- a/google/internal/externalaccount/basecredentials_test.go +++ b/google/internal/externalaccount/basecredentials_test.go @@ -19,14 +19,13 @@ var testBaseCredSource = CredentialSource{ } 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"}, + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, } var ( @@ -55,7 +54,7 @@ func TestToken(t *testing.T) { } body, err := ioutil.ReadAll(r.Body) if err != nil { - t.Errorf("Failed reading request body: %s.", err) + t.Fatalf("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) diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/externalaccount/impersonate.go new file mode 100644 index 0000000..4c22aac --- /dev/null +++ b/google/internal/externalaccount/impersonate.go @@ -0,0 +1,79 @@ +// 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" + "fmt" + "golang.org/x/oauth2" + "io" + "io/ioutil" + "net/http" + "time" +) + +// generateAccesstokenReq is used for service account impersonation +type generateAccessTokenReq struct { + Delegates []string `json:"delegates,omitempty"` + Lifetime string `json:"lifetime,omitempty"` + Scope []string `json:"scope,omitempty"` +} + +type impersonateTokenResponse struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` +} + +func (ts tokenSource) impersonate() (*oauth2.Token, error) { + reqBody := generateAccessTokenReq{ + Lifetime: "3600s", + Scope: ts.conf.Scopes, + } + b, err := json.Marshal(reqBody) + + serviceAccountImpersonationURL := ts.conf.ServiceAccountImpersonationURL + ts.conf.ServiceAccountImpersonationURL = "" + ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} + + client := oauth2.NewClient(ts.ctx, ts) + if err != nil { + return &oauth2.Token{}, fmt.Errorf("google: unable to marshal request: %v", err) + } + req, err := http.NewRequest("POST", serviceAccountImpersonationURL, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to create request: %v", err) + } + req = req.WithContext(ts.ctx) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var accessTokenResp impersonateTokenResponse + if err := json.Unmarshal(body, &accessTokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) + } + expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err) + } + return &oauth2.Token{ + AccessToken: accessTokenResp.AccessToken, + Expiry: expiry, + TokenType: "Bearer", + }, nil + +} diff --git a/google/internal/externalaccount/impersonate_test.go b/google/internal/externalaccount/impersonate_test.go new file mode 100644 index 0000000..302a175 --- /dev/null +++ b/google/internal/externalaccount/impersonate_test.go @@ -0,0 +1,100 @@ +// 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" +) + +var testImpersonateConfig = Config{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, +} + +var ( + baseImpersonateCredsReqBody = "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%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" + baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}` +) + +func TestImpersonation(t *testing.T) { + impersonateServer := 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, "Bearer Sample.Access.Token"; got != want { + t.Errorf("got %v but want %v", got, want) + } + headerContentType := r.Header.Get("Content-Type") + if got, want := headerContentType, "application/json"; got != want { + t.Errorf("got %v but want %v", got, want) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed reading request body: %v.", err) + } + if got, want := string(body), "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}"; got != want { + t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(baseImpersonateCredsRespBody)) + })) + testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL + 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.Fatalf("Failed reading request body: %v.", err) + } + if got, want := string(body), baseImpersonateCredsReqBody; 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() + + testImpersonateConfig.TokenURL = targetServer.URL + ourTS := tokenSource{ + ctx: context.Background(), + conf: &testImpersonateConfig, + } + + 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, "Second.Access.Token"; 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) + } + +} From 0ad52e5ecd981b708195d27afceb766c141b85b3 Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Tue, 19 Jan 2021 16:50:28 -0800 Subject: [PATCH 8/8] Trying to fix authorship CLA issues. Change-Id: I80d6785fd0e1e4f69c5e47d4ab9012ef5575333c --- google/internal/externalaccount/impersonate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/externalaccount/impersonate.go index 4c22aac..98be711 100644 --- a/google/internal/externalaccount/impersonate.go +++ b/google/internal/externalaccount/impersonate.go @@ -27,6 +27,7 @@ type impersonateTokenResponse struct { ExpireTime string `json:"expireTime"` } +// impersonate performs the exchange to get a temporary service account func (ts tokenSource) impersonate() (*oauth2.Token, error) { reqBody := generateAccessTokenReq{ Lifetime: "3600s",