diff --git a/README.md b/README.md index 8cfd606..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) -[![GoDoc](https://godoc.org/golang.org/x/oauth2?status.svg)](https://godoc.org/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 diff --git a/google/google.go b/google/google.go index 81de32b..2c8f1bd 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,15 @@ 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 +152,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..56284c8 --- /dev/null +++ b/google/internal/externalaccount/basecredentials.go @@ -0,0 +1,142 @@ +// 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(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 +} + +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 + + 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") + } + 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..eb60899 --- /dev/null +++ b/google/internal/externalaccount/basecredentials_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" + "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", + 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.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) + } + 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..56dd71e --- /dev/null +++ b/google/internal/externalaccount/filecredsource_test.go @@ -0,0 +1,68 @@ +// 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" + "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(context.Background()).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/impersonate.go b/google/internal/externalaccount/impersonate.go new file mode 100644 index 0000000..98be711 --- /dev/null +++ b/google/internal/externalaccount/impersonate.go @@ -0,0 +1,80 @@ +// 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"` +} + +// impersonate performs the exchange to get a temporary service account +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) + } + +} 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) 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 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) + } +}