// 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" "net/http" "net/url" "regexp" "strconv" "strings" "time" "golang.org/x/oauth2" ) // now aliases time.Now for testing var now = func() time.Time { return time.Now().UTC() } // Config stores the configuration for fetching tokens with external credentials. type Config struct { // Audience is the Secure Token Service (STS) audience which contains the resource name for the workload // identity pool or the workforce pool and the provider identifier in that pool. Audience string // SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec // e.g. `urn:ietf:params:oauth:token-type:jwt`. SubjectTokenType string // TokenURL is the STS token exchange endpoint. TokenURL string // TokenInfoURL is the token_info endpoint used to retrieve the account related information ( // user attributes like account identifier, eg. email, username, uid, etc). This is // needed for gCloud session account identification. TokenInfoURL string // ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only // required for workload identity pools when APIs to be accessed have not integrated with UberMint. ServiceAccountImpersonationURL string // ClientSecret is currently only required if token_info endpoint also // needs to be called with the generated GCP access token. When provided, STS will be // called with additional basic authentication using client_id as username and client_secret as password. ClientSecret string // ClientID is only required in conjunction with ClientSecret, as described above. ClientID string // CredentialSource contains the necessary information to retrieve the token itself, as well // as some environmental information. CredentialSource CredentialSource // QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries // will set the x-goog-user-project which overrides the project associated with the credentials. QuotaProjectID string // Scopes contains the desired scopes for the returned access token. Scopes []string // The optional workforce pool user project number when the credential // corresponds to a workforce pool and not a workload identity pool. // The underlying principal must still have serviceusage.services.use IAM // permission to use the project for billing/quota. WorkforcePoolUserProject string } // Each element consists of a list of patterns. validateURLs checks for matches // that include all elements in a given list, in that order. var ( validTokenURLPatterns = []*regexp.Regexp{ // The complicated part in the middle matches any number of characters that // aren't period, spaces, or slashes. regexp.MustCompile(`(?i)^[^\.\s\/\\]+\.sts\.googleapis\.com$`), regexp.MustCompile(`(?i)^sts\.googleapis\.com$`), regexp.MustCompile(`(?i)^sts\.[^\.\s\/\\]+\.googleapis\.com$`), regexp.MustCompile(`(?i)^[^\.\s\/\\]+-sts\.googleapis\.com$`), } validImpersonateURLPatterns = []*regexp.Regexp{ regexp.MustCompile(`^[^\.\s\/\\]+\.iamcredentials\.googleapis\.com$`), regexp.MustCompile(`^iamcredentials\.googleapis\.com$`), regexp.MustCompile(`^iamcredentials\.[^\.\s\/\\]+\.googleapis\.com$`), regexp.MustCompile(`^[^\.\s\/\\]+-iamcredentials\.googleapis\.com$`), } validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`) ) func validateURL(input string, patterns []*regexp.Regexp, scheme string) bool { parsed, err := url.Parse(input) if err != nil { return false } if !strings.EqualFold(parsed.Scheme, scheme) { return false } toTest := parsed.Host for _, pattern := range patterns { if pattern.MatchString(toTest) { return true } } return false } func validateWorkforceAudience(input string) bool { return validWorkforceAudiencePattern.MatchString(input) } // 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, error) { return c.tokenSource(ctx, validTokenURLPatterns, validImpersonateURLPatterns, "https") } // tokenSource is a private function that's directly called by some of the tests, // because the unit test URLs are mocked, and would otherwise fail the // validity check. func (c *Config) tokenSource(ctx context.Context, tokenURLValidPats []*regexp.Regexp, impersonateURLValidPats []*regexp.Regexp, scheme string) (oauth2.TokenSource, error) { valid := validateURL(c.TokenURL, tokenURLValidPats, scheme) if !valid { return nil, fmt.Errorf("oauth2/google: invalid TokenURL provided while constructing tokenSource") } if c.ServiceAccountImpersonationURL != "" { valid := validateURL(c.ServiceAccountImpersonationURL, impersonateURLValidPats, scheme) if !valid { return nil, fmt.Errorf("oauth2/google: invalid ServiceAccountImpersonationURL provided while constructing tokenSource") } } if c.WorkforcePoolUserProject != "" { valid := validateWorkforceAudience(c.Audience) if !valid { return nil, fmt.Errorf("oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials") } } ts := tokenSource{ ctx: ctx, conf: c, } if c.ServiceAccountImpersonationURL == "" { return oauth2.ReuseTokenSource(nil, ts), nil } scopes := c.Scopes ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} imp := ImpersonateTokenSource{ Ctx: ctx, URL: c.ServiceAccountImpersonationURL, Scopes: scopes, Ts: oauth2.ReuseTokenSource(nil, ts), } return oauth2.ReuseTokenSource(nil, imp), nil } // 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. // Either the File or the URL field should be filled, depending on the kind of credential in question. // The EnvironmentID should start with AWS if being used for an AWS credential. 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"` IMDSv2SessionTokenURL string `json:"imdsv2_session_token_url"` Format format `json:"format"` } // parse determines the type of CredentialSource needed func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" { if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil { if awsVersion != 1 { return nil, fmt.Errorf("oauth2/google: aws version '%d' is not supported in the current build", awsVersion) } awsCredSource := awsCredentialSource{ EnvironmentID: c.CredentialSource.EnvironmentID, RegionURL: c.CredentialSource.RegionURL, RegionalCredVerificationURL: c.CredentialSource.RegionalCredVerificationURL, CredVerificationURL: c.CredentialSource.URL, TargetResource: c.Audience, ctx: ctx, } if c.CredentialSource.IMDSv2SessionTokenURL != "" { awsCredSource.IMDSv2SessionTokenURL = c.CredentialSource.IMDSv2SessionTokenURL } return awsCredSource, nil } } else if c.CredentialSource.File != "" { return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil } else if c.CredentialSource.URL != "" { return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil } return nil, fmt.Errorf("oauth2/google: unable to parse credential source") } type baseCredentialSource interface { subjectToken() (string, error) } // tokenSource is the source that handles external credentials. It is used to retrieve Tokens. 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, err := conf.parse(ts.ctx) if err != nil { return nil, err } 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, } var options map[string]interface{} // Do not pass workforce_pool_user_project when client authentication is used. // The client ID is sufficient for determining the user project. if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" { options = map[string]interface{}{ "userProject": conf.WorkforcePoolUserProject, } } stsResp, err := exchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options) 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 }