// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package externalaccount import ( "context" "errors" "fmt" "net/http" "regexp" "strconv" "strings" "time" "cloud.google.com/go/auth" "cloud.google.com/go/auth/credentials/internal/impersonate" "cloud.google.com/go/auth/credentials/internal/stsexchange" "cloud.google.com/go/auth/internal/credsfile" ) const ( timeoutMinimum = 5 * time.Second timeoutMaximum = 120 * time.Second universeDomainPlaceholder = "UNIVERSE_DOMAIN" defaultTokenURL = "https://sts.UNIVERSE_DOMAIN/v1/token" defaultUniverseDomain = "googleapis.com" ) var ( // Now aliases time.Now for testing Now = func() time.Time { return time.Now().UTC() } validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`) ) // Options stores the configuration for fetching tokens with external credentials. type Options 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 // ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation // token will be valid for. ServiceAccountImpersonationLifetimeSeconds int // 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 *credsfile.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 // WorkforcePoolUserProject should be set when it is 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. Optional. WorkforcePoolUserProject string // UniverseDomain is the default service domain for a given Cloud universe. // This value will be used in the default STS token URL. The default value // is "googleapis.com". It will not be used if TokenURL is set. Optional. UniverseDomain string // SubjectTokenProvider is an optional token provider for OIDC/SAML // credentials. One of SubjectTokenProvider, AWSSecurityCredentialProvider // or CredentialSource must be provided. Optional. SubjectTokenProvider SubjectTokenProvider // AwsSecurityCredentialsProvider is an AWS Security Credential provider // for AWS credentials. One of SubjectTokenProvider, // AWSSecurityCredentialProvider or CredentialSource must be provided. Optional. AwsSecurityCredentialsProvider AwsSecurityCredentialsProvider // Client for token request. Client *http.Client } // SubjectTokenProvider can be used to supply a subject token to exchange for a // GCP access token. type SubjectTokenProvider interface { // SubjectToken should return a valid subject token or an error. // The external account token provider does not cache the returned subject // token, so caching logic should be implemented in the provider to prevent // multiple requests for the same subject token. SubjectToken(ctx context.Context, opts *RequestOptions) (string, error) } // RequestOptions contains information about the requested subject token or AWS // security credentials from the Google external account credential. type RequestOptions struct { // Audience is the requested audience for the external account credential. Audience string // Subject token type is the requested subject token type for the external // account credential. Expected values include: // “urn:ietf:params:oauth:token-type:jwt” // “urn:ietf:params:oauth:token-type:id-token” // “urn:ietf:params:oauth:token-type:saml2” // “urn:ietf:params:aws:token-type:aws4_request” SubjectTokenType string } // AwsSecurityCredentialsProvider can be used to supply AwsSecurityCredentials // and an AWS Region to exchange for a GCP access token. type AwsSecurityCredentialsProvider interface { // AwsRegion should return the AWS region or an error. AwsRegion(ctx context.Context, opts *RequestOptions) (string, error) // GetAwsSecurityCredentials should return a valid set of // AwsSecurityCredentials or an error. The external account token provider // does not cache the returned security credentials, so caching logic should // be implemented in the provider to prevent multiple requests for the // same security credentials. AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error) } // AwsSecurityCredentials models AWS security credentials. type AwsSecurityCredentials struct { // AccessKeyId is the AWS Access Key ID - Required. AccessKeyID string `json:"AccessKeyID"` // SecretAccessKey is the AWS Secret Access Key - Required. SecretAccessKey string `json:"SecretAccessKey"` // SessionToken is the AWS Session token. This should be provided for // temporary AWS security credentials - Optional. SessionToken string `json:"Token"` } func (o *Options) validate() error { if o.Audience == "" { return fmt.Errorf("externalaccount: Audience must be set") } if o.SubjectTokenType == "" { return fmt.Errorf("externalaccount: Subject token type must be set") } if o.WorkforcePoolUserProject != "" { if valid := validWorkforceAudiencePattern.MatchString(o.Audience); !valid { return fmt.Errorf("externalaccount: workforce_pool_user_project should not be set for non-workforce pool credentials") } } count := 0 if o.CredentialSource != nil { count++ } if o.SubjectTokenProvider != nil { count++ } if o.AwsSecurityCredentialsProvider != nil { count++ } if count == 0 { return fmt.Errorf("externalaccount: one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set") } if count > 1 { return fmt.Errorf("externalaccount: only one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set") } return nil } // resolveTokenURL sets the default STS token endpoint with the configured // universe domain. func (o *Options) resolveTokenURL() { if o.TokenURL != "" { return } else if o.UniverseDomain != "" { o.TokenURL = strings.Replace(defaultTokenURL, universeDomainPlaceholder, o.UniverseDomain, 1) } else { o.TokenURL = strings.Replace(defaultTokenURL, universeDomainPlaceholder, defaultUniverseDomain, 1) } } // NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] // configured with the provided options. func NewTokenProvider(opts *Options) (auth.TokenProvider, error) { if err := opts.validate(); err != nil { return nil, err } opts.resolveTokenURL() stp, err := newSubjectTokenProvider(opts) if err != nil { return nil, err } tp := &tokenProvider{ client: opts.Client, opts: opts, stp: stp, } if opts.ServiceAccountImpersonationURL == "" { return auth.NewCachedTokenProvider(tp, nil), nil } scopes := make([]string, len(opts.Scopes)) copy(scopes, opts.Scopes) // needed for impersonation tp.opts.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} imp, err := impersonate.NewTokenProvider(&impersonate.Options{ Client: opts.Client, URL: opts.ServiceAccountImpersonationURL, Scopes: scopes, Tp: auth.NewCachedTokenProvider(tp, nil), TokenLifetimeSeconds: opts.ServiceAccountImpersonationLifetimeSeconds, }) if err != nil { return nil, err } return auth.NewCachedTokenProvider(imp, nil), nil } type subjectTokenProvider interface { subjectToken(ctx context.Context) (string, error) providerType() string } // tokenProvider is the provider that handles external credentials. It is used to retrieve Tokens. type tokenProvider struct { client *http.Client opts *Options stp subjectTokenProvider } func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) { subjectToken, err := tp.stp.subjectToken(ctx) if err != nil { return nil, err } stsRequest := &stsexchange.TokenRequest{ GrantType: stsexchange.GrantType, Audience: tp.opts.Audience, Scope: tp.opts.Scopes, RequestedTokenType: stsexchange.TokenType, SubjectToken: subjectToken, SubjectTokenType: tp.opts.SubjectTokenType, } header := make(http.Header) header.Set("Content-Type", "application/x-www-form-urlencoded") header.Add("x-goog-api-client", getGoogHeaderValue(tp.opts, tp.stp)) clientAuth := stsexchange.ClientAuthentication{ AuthStyle: auth.StyleInHeader, ClientID: tp.opts.ClientID, ClientSecret: tp.opts.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 tp.opts.WorkforcePoolUserProject != "" && tp.opts.ClientID == "" { options = map[string]interface{}{ "userProject": tp.opts.WorkforcePoolUserProject, } } stsResp, err := stsexchange.ExchangeToken(ctx, &stsexchange.Options{ Client: tp.client, Endpoint: tp.opts.TokenURL, Request: stsRequest, Authentication: clientAuth, Headers: header, ExtraOpts: options, }) if err != nil { return nil, err } tok := &auth.Token{ Value: stsResp.AccessToken, Type: stsResp.TokenType, } // The RFC8693 doesn't define the explicit 0 of "expires_in" field behavior. if stsResp.ExpiresIn <= 0 { return nil, fmt.Errorf("credentials: got invalid expiry from security token service") } tok.Expiry = Now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) return tok, nil } // newSubjectTokenProvider determines the type of credsfile.CredentialSource needed to create a // subjectTokenProvider func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) { reqOpts := &RequestOptions{Audience: o.Audience, SubjectTokenType: o.SubjectTokenType} if o.AwsSecurityCredentialsProvider != nil { return &awsSubjectProvider{ securityCredentialsProvider: o.AwsSecurityCredentialsProvider, TargetResource: o.Audience, reqOpts: reqOpts, }, nil } else if o.SubjectTokenProvider != nil { return &programmaticProvider{stp: o.SubjectTokenProvider, opts: reqOpts}, nil } else if len(o.CredentialSource.EnvironmentID) > 3 && o.CredentialSource.EnvironmentID[:3] == "aws" { if awsVersion, err := strconv.Atoi(o.CredentialSource.EnvironmentID[3:]); err == nil { if awsVersion != 1 { return nil, fmt.Errorf("credentials: aws version '%d' is not supported in the current build", awsVersion) } awsProvider := &awsSubjectProvider{ EnvironmentID: o.CredentialSource.EnvironmentID, RegionURL: o.CredentialSource.RegionURL, RegionalCredVerificationURL: o.CredentialSource.RegionalCredVerificationURL, CredVerificationURL: o.CredentialSource.URL, TargetResource: o.Audience, Client: o.Client, } if o.CredentialSource.IMDSv2SessionTokenURL != "" { awsProvider.IMDSv2SessionTokenURL = o.CredentialSource.IMDSv2SessionTokenURL } return awsProvider, nil } } else if o.CredentialSource.File != "" { return &fileSubjectProvider{File: o.CredentialSource.File, Format: o.CredentialSource.Format}, nil } else if o.CredentialSource.URL != "" { return &urlSubjectProvider{URL: o.CredentialSource.URL, Headers: o.CredentialSource.Headers, Format: o.CredentialSource.Format, Client: o.Client}, nil } else if o.CredentialSource.Executable != nil { ec := o.CredentialSource.Executable if ec.Command == "" { return nil, errors.New("credentials: missing `command` field — executable command must be provided") } execProvider := &executableSubjectProvider{} execProvider.Command = ec.Command if ec.TimeoutMillis == 0 { execProvider.Timeout = executableDefaultTimeout } else { execProvider.Timeout = time.Duration(ec.TimeoutMillis) * time.Millisecond if execProvider.Timeout < timeoutMinimum || execProvider.Timeout > timeoutMaximum { return nil, fmt.Errorf("credentials: invalid `timeout_millis` field — executable timeout must be between %v and %v seconds", timeoutMinimum.Seconds(), timeoutMaximum.Seconds()) } } execProvider.OutputFile = ec.OutputFile execProvider.client = o.Client execProvider.opts = o execProvider.env = runtimeEnvironment{} return execProvider, nil } return nil, errors.New("credentials: unable to parse credential source") } func getGoogHeaderValue(conf *Options, p subjectTokenProvider) string { return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), "unknown", p.providerType(), conf.ServiceAccountImpersonationURL != "", conf.ServiceAccountImpersonationLifetimeSeconds != 0) }