diff --git a/google/google.go b/google/google.go index 81de32b..a7cc83b 100644 --- a/google/google.go +++ b/google/google.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/oauth2/google/internal/externalaccount" "net/url" "strings" "time" @@ -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..ec8598a --- /dev/null +++ b/google/internal/externalaccount/basecredentials.go @@ -0,0 +1,147 @@ +package externalaccount + +import ( + "context" + "fmt" + "golang.org/x/oauth2" + "net/http" + "time" +) + +// 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 +} + +// Returns an external account TokenSource. 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 { + // Either "text" or "json". When not provided "text" type is assumed. + Type string `json:"type"` + // Only required for JSON. + // This would be "access_token" for azure. + SubjectTokenFieldName string `json:"subject_token_field_name"` +} + +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"` +} + +func (cs CredentialSource) instance() baseCredentialSource { + if cs.EnvironmentID == "awsX" { + return nil + //return awsCredentialSource{EnvironmentID:cs.EnvironmentID, RegionURL:cs.RegionURL, RegionalCredVerificationURL: cs.RegionalCredVerificationURL, CredVerificationURL:cs.CredVerificationURL} + } else if cs.File == "internalTestingFile" { + return testCredentialSource{} + } else if cs.File != "" { + return fileCredentialSource{File: cs.File} + } else if cs.URL != "" { + //return urlCredentialSource{URL:cs.URL, Headers:cs.Headers} + return nil + } else { + return nil + } +} + +type baseCredentialSource interface { + retrieveSubjectToken(c *Config) (string, error) +} + +// tokenSource is the source that handles 3PI credentials. +type tokenSource struct { + ctx context.Context + conf *Config +} + +// This method is implemented so that tokenSource conforms to oauth2.TokenSource. +func (ts tokenSource) Token() (*oauth2.Token, error) { + conf := ts.conf + + subjectToken, err := conf.CredentialSource.instance().retrieveSubjectToken(conf) + if err != nil { + return &oauth2.Token{}, 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 { + fmt.Errorf("oauth2/google: %s", err.Error()) + return &oauth2.Token{}, err + } + + accessToken := &oauth2.Token{ + AccessToken: stsResp.AccessToken, + TokenType: stsResp.TokenType, + } + if stsResp.ExpiresIn < 0 { + fmt.Errorf("google/oauth2: got invalid expiry from security token service") + // REVIEWERS: Should I return the Token that I actually got back here so that people could inspect the result even with a improper ExpiresIn response? + // Or is it more appropriate to still return an empty token: &oauth2.Token{} so that anybody who checks for an empty token as a sign of failure doesn't get confused. + return accessToken, nil + } else if stsResp.ExpiresIn > 0 { + accessToken.Expiry = time.Now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) + } + + if stsResp.RefreshToken != "" { + accessToken.RefreshToken = stsResp.RefreshToken + } + + return accessToken, nil +} + +// NOTE: this method doesn't exist yet. It is being investigated to add this method to oauth2.TokenSource. +//func (ts tokenSource) TokenInfo() (*oauth2.TokenInfo, error) + +// testCredentialSource is only used for testing, but must be defined here in order to avoid undefined errors when testing. +type testCredentialSource struct { + File string +} + +func (cs testCredentialSource) retrieveSubjectToken(c *Config) (string, error) { + return "Sample.Subject.Token", nil +} diff --git a/google/internal/externalaccount/basecredentials_test.go b/google/internal/externalaccount/basecredentials_test.go new file mode 100644 index 0000000..7d97c00 --- /dev/null +++ b/google/internal/externalaccount/basecredentials_test.go @@ -0,0 +1,78 @@ +package externalaccount + +import ( + "context" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) + +var testBaseCredSource = CredentialSource{ + File: "internalTestingFile", +} + +var testConfig = 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", + 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=Sample.Subject.Token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" +var 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"}` + +var correctAT = "Sample.Access.Token" + +func TestToken_Func(t *testing.T) { + + targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + /*I'm not sure whether this testing is necessary or not. There's an argument that it should be here for completeness, + but it's also just mimicking similar testing done in sts_exchange_test.go + */ + if got, want := r.URL.String(), "/"; got != want { + t.Errorf("Unexpected request URL: got %v but want %v", got, want) + } + headerAuth := r.Header.Get("Authorization") + if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want { + t.Errorf("Unexpected autohrization header: 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("Unexpected Content-Type header: 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)) + })) + + testConfig.TokenURL = targetServer.URL + ourTS := tokenSource{ + ctx: context.Background(), + conf: &testConfig, + } + + tok, err := ourTS.Token() + if err != nil { + t.Errorf("Unexpected error: %e", err) + } + if tok.AccessToken != correctAT { + t.Errorf("Unexpected access token: got %v, but wanted %v", tok.AccessToken, correctAT) + } + if tok.TokenType != "Bearer" { + t.Errorf("Unexpected TokenType: got %v, but wanted \"Bearer\"", tok.TokenType) + } + //We don't check the correct expiry here because that's dependent on the current time. + +} diff --git a/google/internal/externalaccount/filecredsource.go b/google/internal/externalaccount/filecredsource.go new file mode 100644 index 0000000..27b3a7e --- /dev/null +++ b/google/internal/externalaccount/filecredsource.go @@ -0,0 +1,49 @@ +package externalaccount + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" +) + +type fileCredentialSource struct { + File string +} + +func (cs fileCredentialSource) retrieveSubjectToken(c *Config) (string, error) { + tokenFile, err := os.Open(cs.File) + if err != nil { + return "", fmt.Errorf("Failed to open credential file %s\n", cs.File) + } + tokenBytes, _ := ioutil.ReadAll(tokenFile) + if string(tokenBytes[len(tokenBytes)-1]) == "\n" { //Deals with a possible trailing newline character + tokenBytes = tokenBytes[0 : len(tokenBytes)-1] + } + var output string + switch c.CredentialSource.Format.Type { + case "json": + jsonData := make(map[string]interface{}) + json.Unmarshal(tokenBytes, &jsonData) + if val, ok := jsonData[c.CredentialSource.Format.SubjectTokenFieldName]; !ok { + return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") + } else { + if token, ok := val.(string); !ok { + return "", errors.New("oauth2/google: improperly formatted subject token") + } else { + output = token + } + + } + case "text": + output = string(tokenBytes) + case "": + output = string(tokenBytes) + default: + return "", errors.New("oauth2/google: invalid credential_source file format type") + } + + return output, nil + +} diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/internal/externalaccount/filecredsource_test.go new file mode 100644 index 0000000..dc46427 --- /dev/null +++ b/google/internal/externalaccount/filecredsource_test.go @@ -0,0 +1,61 @@ +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", +} + +type fsTest struct { + name string + cs CredentialSource + want string +} + +var testFsUntyped = fsTest{ + name: "UntypedFileSource", + cs: CredentialSource{ + File: "../../testdata/externalaccount/3pi_cred.txt", + }, + want: "street123", +} +var testFsTypeText = fsTest{ + name: "TextFileSource", + cs: CredentialSource{ + File: "../../testdata/externalaccount/3pi_cred.txt", + Format: format{Type: fileTypeText}, + }, + want: "street123", +} +var testFsTypeJSON = fsTest{ + name: "JSONFileSource", + cs: CredentialSource{ + File: "../../testdata/externalaccount/3pi_cred.json", + Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + }, + want: "321road", +} +var fileSourceTests = []fsTest{testFsUntyped, testFsTypeText, testFsTypeJSON} + +func TestRetrieveFileSubjectToken(t *testing.T) { + for _, test := range fileSourceTests { + tfc := testFileConfig + tfc.CredentialSource = test.cs + + out, err := test.cs.instance().retrieveSubjectToken(&tfc) + if err != nil { + t.Errorf("Method retrieveSubjectToken for type fileCredentialSource in test %v failed; %e", test.name, err) + } + if out != test.want { + t.Errorf("Test %v for method retrieveSubjectToken for type fileCredentialSouce failed: expected %v but got %v", test.name, test.want, out) + } + } +} diff --git a/google/testdata/externalaccount/3pi_cred.json b/google/testdata/externalaccount/3pi_cred.json new file mode 100644 index 0000000..6a9cf7d --- /dev/null +++ b/google/testdata/externalaccount/3pi_cred.json @@ -0,0 +1,3 @@ +{ + "SubjToken": "321road" +} diff --git a/google/testdata/externalaccount/3pi_cred.txt b/google/testdata/externalaccount/3pi_cred.txt new file mode 100644 index 0000000..4e511cc --- /dev/null +++ b/google/testdata/externalaccount/3pi_cred.txt @@ -0,0 +1 @@ +street123