...

Source file src/cloud.google.com/go/auth/credentials/internal/externalaccount/externalaccount.go

Documentation: cloud.google.com/go/auth/credentials/internal/externalaccount

     1  // Copyright 2023 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package externalaccount
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"net/http"
    22  	"regexp"
    23  	"strconv"
    24  	"strings"
    25  	"time"
    26  
    27  	"cloud.google.com/go/auth"
    28  	"cloud.google.com/go/auth/credentials/internal/impersonate"
    29  	"cloud.google.com/go/auth/credentials/internal/stsexchange"
    30  	"cloud.google.com/go/auth/internal/credsfile"
    31  )
    32  
    33  const (
    34  	timeoutMinimum = 5 * time.Second
    35  	timeoutMaximum = 120 * time.Second
    36  
    37  	universeDomainPlaceholder = "UNIVERSE_DOMAIN"
    38  	defaultTokenURL           = "https://sts.UNIVERSE_DOMAIN/v1/token"
    39  	defaultUniverseDomain     = "googleapis.com"
    40  )
    41  
    42  var (
    43  	// Now aliases time.Now for testing
    44  	Now = func() time.Time {
    45  		return time.Now().UTC()
    46  	}
    47  	validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`)
    48  )
    49  
    50  // Options stores the configuration for fetching tokens with external credentials.
    51  type Options struct {
    52  	// Audience is the Secure Token Service (STS) audience which contains the resource name for the workload
    53  	// identity pool or the workforce pool and the provider identifier in that pool.
    54  	Audience string
    55  	// SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec
    56  	// e.g. `urn:ietf:params:oauth:token-type:jwt`.
    57  	SubjectTokenType string
    58  	// TokenURL is the STS token exchange endpoint.
    59  	TokenURL string
    60  	// TokenInfoURL is the token_info endpoint used to retrieve the account related information (
    61  	// user attributes like account identifier, eg. email, username, uid, etc). This is
    62  	// needed for gCloud session account identification.
    63  	TokenInfoURL string
    64  	// ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only
    65  	// required for workload identity pools when APIs to be accessed have not integrated with UberMint.
    66  	ServiceAccountImpersonationURL string
    67  	// ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation
    68  	// token will be valid for.
    69  	ServiceAccountImpersonationLifetimeSeconds int
    70  	// ClientSecret is currently only required if token_info endpoint also
    71  	// needs to be called with the generated GCP access token. When provided, STS will be
    72  	// called with additional basic authentication using client_id as username and client_secret as password.
    73  	ClientSecret string
    74  	// ClientID is only required in conjunction with ClientSecret, as described above.
    75  	ClientID string
    76  	// CredentialSource contains the necessary information to retrieve the token itself, as well
    77  	// as some environmental information.
    78  	CredentialSource *credsfile.CredentialSource
    79  	// QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries
    80  	// will set the x-goog-user-project which overrides the project associated with the credentials.
    81  	QuotaProjectID string
    82  	// Scopes contains the desired scopes for the returned access token.
    83  	Scopes []string
    84  	// WorkforcePoolUserProject should be set when it is a workforce pool and
    85  	// not a workload identity pool. The underlying principal must still have
    86  	// serviceusage.services.use IAM permission to use the project for
    87  	// billing/quota. Optional.
    88  	WorkforcePoolUserProject string
    89  	// UniverseDomain is the default service domain for a given Cloud universe.
    90  	// This value will be used in the default STS token URL. The default value
    91  	// is "googleapis.com". It will not be used if TokenURL is set. Optional.
    92  	UniverseDomain string
    93  	// SubjectTokenProvider is an optional token provider for OIDC/SAML
    94  	// credentials. One of SubjectTokenProvider, AWSSecurityCredentialProvider
    95  	// or CredentialSource must be provided. Optional.
    96  	SubjectTokenProvider SubjectTokenProvider
    97  	// AwsSecurityCredentialsProvider is an AWS Security Credential provider
    98  	// for AWS credentials. One of SubjectTokenProvider,
    99  	// AWSSecurityCredentialProvider or CredentialSource must be provided. Optional.
   100  	AwsSecurityCredentialsProvider AwsSecurityCredentialsProvider
   101  	// Client for token request.
   102  	Client *http.Client
   103  }
   104  
   105  // SubjectTokenProvider can be used to supply a subject token to exchange for a
   106  // GCP access token.
   107  type SubjectTokenProvider interface {
   108  	// SubjectToken should return a valid subject token or an error.
   109  	// The external account token provider does not cache the returned subject
   110  	// token, so caching logic should be implemented in the provider to prevent
   111  	// multiple requests for the same subject token.
   112  	SubjectToken(ctx context.Context, opts *RequestOptions) (string, error)
   113  }
   114  
   115  // RequestOptions contains information about the requested subject token or AWS
   116  // security credentials from the Google external account credential.
   117  type RequestOptions struct {
   118  	// Audience is the requested audience for the external account credential.
   119  	Audience string
   120  	// Subject token type is the requested subject token type for the external
   121  	// account credential. Expected values include:
   122  	// “urn:ietf:params:oauth:token-type:jwt”
   123  	// “urn:ietf:params:oauth:token-type:id-token”
   124  	// “urn:ietf:params:oauth:token-type:saml2”
   125  	// “urn:ietf:params:aws:token-type:aws4_request”
   126  	SubjectTokenType string
   127  }
   128  
   129  // AwsSecurityCredentialsProvider can be used to supply AwsSecurityCredentials
   130  // and an AWS Region to exchange for a GCP access token.
   131  type AwsSecurityCredentialsProvider interface {
   132  	// AwsRegion should return the AWS region or an error.
   133  	AwsRegion(ctx context.Context, opts *RequestOptions) (string, error)
   134  	// GetAwsSecurityCredentials should return a valid set of
   135  	// AwsSecurityCredentials or an error. The external account token provider
   136  	// does not cache the returned security credentials, so caching logic should
   137  	// be implemented in the provider to prevent multiple requests for the
   138  	// same security credentials.
   139  	AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error)
   140  }
   141  
   142  // AwsSecurityCredentials models AWS security credentials.
   143  type AwsSecurityCredentials struct {
   144  	// AccessKeyId is the AWS Access Key ID - Required.
   145  	AccessKeyID string `json:"AccessKeyID"`
   146  	// SecretAccessKey is the AWS Secret Access Key - Required.
   147  	SecretAccessKey string `json:"SecretAccessKey"`
   148  	// SessionToken is the AWS Session token. This should be provided for
   149  	// temporary AWS security credentials - Optional.
   150  	SessionToken string `json:"Token"`
   151  }
   152  
   153  func (o *Options) validate() error {
   154  	if o.Audience == "" {
   155  		return fmt.Errorf("externalaccount: Audience must be set")
   156  	}
   157  	if o.SubjectTokenType == "" {
   158  		return fmt.Errorf("externalaccount: Subject token type must be set")
   159  	}
   160  	if o.WorkforcePoolUserProject != "" {
   161  		if valid := validWorkforceAudiencePattern.MatchString(o.Audience); !valid {
   162  			return fmt.Errorf("externalaccount: workforce_pool_user_project should not be set for non-workforce pool credentials")
   163  		}
   164  	}
   165  	count := 0
   166  	if o.CredentialSource != nil {
   167  		count++
   168  	}
   169  	if o.SubjectTokenProvider != nil {
   170  		count++
   171  	}
   172  	if o.AwsSecurityCredentialsProvider != nil {
   173  		count++
   174  	}
   175  	if count == 0 {
   176  		return fmt.Errorf("externalaccount: one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set")
   177  	}
   178  	if count > 1 {
   179  		return fmt.Errorf("externalaccount: only one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set")
   180  	}
   181  	return nil
   182  }
   183  
   184  // resolveTokenURL sets the default STS token endpoint with the configured
   185  // universe domain.
   186  func (o *Options) resolveTokenURL() {
   187  	if o.TokenURL != "" {
   188  		return
   189  	} else if o.UniverseDomain != "" {
   190  		o.TokenURL = strings.Replace(defaultTokenURL, universeDomainPlaceholder, o.UniverseDomain, 1)
   191  	} else {
   192  		o.TokenURL = strings.Replace(defaultTokenURL, universeDomainPlaceholder, defaultUniverseDomain, 1)
   193  	}
   194  }
   195  
   196  // NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider]
   197  // configured with the provided options.
   198  func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
   199  	if err := opts.validate(); err != nil {
   200  		return nil, err
   201  	}
   202  	opts.resolveTokenURL()
   203  	stp, err := newSubjectTokenProvider(opts)
   204  	if err != nil {
   205  		return nil, err
   206  	}
   207  	tp := &tokenProvider{
   208  		client: opts.Client,
   209  		opts:   opts,
   210  		stp:    stp,
   211  	}
   212  	if opts.ServiceAccountImpersonationURL == "" {
   213  		return auth.NewCachedTokenProvider(tp, nil), nil
   214  	}
   215  
   216  	scopes := make([]string, len(opts.Scopes))
   217  	copy(scopes, opts.Scopes)
   218  	// needed for impersonation
   219  	tp.opts.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
   220  	imp, err := impersonate.NewTokenProvider(&impersonate.Options{
   221  		Client:               opts.Client,
   222  		URL:                  opts.ServiceAccountImpersonationURL,
   223  		Scopes:               scopes,
   224  		Tp:                   auth.NewCachedTokenProvider(tp, nil),
   225  		TokenLifetimeSeconds: opts.ServiceAccountImpersonationLifetimeSeconds,
   226  	})
   227  	if err != nil {
   228  		return nil, err
   229  	}
   230  	return auth.NewCachedTokenProvider(imp, nil), nil
   231  }
   232  
   233  type subjectTokenProvider interface {
   234  	subjectToken(ctx context.Context) (string, error)
   235  	providerType() string
   236  }
   237  
   238  // tokenProvider is the provider that handles external credentials. It is used to retrieve Tokens.
   239  type tokenProvider struct {
   240  	client *http.Client
   241  	opts   *Options
   242  	stp    subjectTokenProvider
   243  }
   244  
   245  func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) {
   246  	subjectToken, err := tp.stp.subjectToken(ctx)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  
   251  	stsRequest := &stsexchange.TokenRequest{
   252  		GrantType:          stsexchange.GrantType,
   253  		Audience:           tp.opts.Audience,
   254  		Scope:              tp.opts.Scopes,
   255  		RequestedTokenType: stsexchange.TokenType,
   256  		SubjectToken:       subjectToken,
   257  		SubjectTokenType:   tp.opts.SubjectTokenType,
   258  	}
   259  	header := make(http.Header)
   260  	header.Set("Content-Type", "application/x-www-form-urlencoded")
   261  	header.Add("x-goog-api-client", getGoogHeaderValue(tp.opts, tp.stp))
   262  	clientAuth := stsexchange.ClientAuthentication{
   263  		AuthStyle:    auth.StyleInHeader,
   264  		ClientID:     tp.opts.ClientID,
   265  		ClientSecret: tp.opts.ClientSecret,
   266  	}
   267  	var options map[string]interface{}
   268  	// Do not pass workforce_pool_user_project when client authentication is used.
   269  	// The client ID is sufficient for determining the user project.
   270  	if tp.opts.WorkforcePoolUserProject != "" && tp.opts.ClientID == "" {
   271  		options = map[string]interface{}{
   272  			"userProject": tp.opts.WorkforcePoolUserProject,
   273  		}
   274  	}
   275  	stsResp, err := stsexchange.ExchangeToken(ctx, &stsexchange.Options{
   276  		Client:         tp.client,
   277  		Endpoint:       tp.opts.TokenURL,
   278  		Request:        stsRequest,
   279  		Authentication: clientAuth,
   280  		Headers:        header,
   281  		ExtraOpts:      options,
   282  	})
   283  	if err != nil {
   284  		return nil, err
   285  	}
   286  
   287  	tok := &auth.Token{
   288  		Value: stsResp.AccessToken,
   289  		Type:  stsResp.TokenType,
   290  	}
   291  	// The RFC8693 doesn't define the explicit 0 of "expires_in" field behavior.
   292  	if stsResp.ExpiresIn <= 0 {
   293  		return nil, fmt.Errorf("credentials: got invalid expiry from security token service")
   294  	}
   295  	tok.Expiry = Now().Add(time.Duration(stsResp.ExpiresIn) * time.Second)
   296  	return tok, nil
   297  }
   298  
   299  // newSubjectTokenProvider determines the type of credsfile.CredentialSource needed to create a
   300  // subjectTokenProvider
   301  func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) {
   302  	reqOpts := &RequestOptions{Audience: o.Audience, SubjectTokenType: o.SubjectTokenType}
   303  	if o.AwsSecurityCredentialsProvider != nil {
   304  		return &awsSubjectProvider{
   305  			securityCredentialsProvider: o.AwsSecurityCredentialsProvider,
   306  			TargetResource:              o.Audience,
   307  			reqOpts:                     reqOpts,
   308  		}, nil
   309  	} else if o.SubjectTokenProvider != nil {
   310  		return &programmaticProvider{stp: o.SubjectTokenProvider, opts: reqOpts}, nil
   311  	} else if len(o.CredentialSource.EnvironmentID) > 3 && o.CredentialSource.EnvironmentID[:3] == "aws" {
   312  		if awsVersion, err := strconv.Atoi(o.CredentialSource.EnvironmentID[3:]); err == nil {
   313  			if awsVersion != 1 {
   314  				return nil, fmt.Errorf("credentials: aws version '%d' is not supported in the current build", awsVersion)
   315  			}
   316  
   317  			awsProvider := &awsSubjectProvider{
   318  				EnvironmentID:               o.CredentialSource.EnvironmentID,
   319  				RegionURL:                   o.CredentialSource.RegionURL,
   320  				RegionalCredVerificationURL: o.CredentialSource.RegionalCredVerificationURL,
   321  				CredVerificationURL:         o.CredentialSource.URL,
   322  				TargetResource:              o.Audience,
   323  				Client:                      o.Client,
   324  			}
   325  			if o.CredentialSource.IMDSv2SessionTokenURL != "" {
   326  				awsProvider.IMDSv2SessionTokenURL = o.CredentialSource.IMDSv2SessionTokenURL
   327  			}
   328  
   329  			return awsProvider, nil
   330  		}
   331  	} else if o.CredentialSource.File != "" {
   332  		return &fileSubjectProvider{File: o.CredentialSource.File, Format: o.CredentialSource.Format}, nil
   333  	} else if o.CredentialSource.URL != "" {
   334  		return &urlSubjectProvider{URL: o.CredentialSource.URL, Headers: o.CredentialSource.Headers, Format: o.CredentialSource.Format, Client: o.Client}, nil
   335  	} else if o.CredentialSource.Executable != nil {
   336  		ec := o.CredentialSource.Executable
   337  		if ec.Command == "" {
   338  			return nil, errors.New("credentials: missing `command` field — executable command must be provided")
   339  		}
   340  
   341  		execProvider := &executableSubjectProvider{}
   342  		execProvider.Command = ec.Command
   343  		if ec.TimeoutMillis == 0 {
   344  			execProvider.Timeout = executableDefaultTimeout
   345  		} else {
   346  			execProvider.Timeout = time.Duration(ec.TimeoutMillis) * time.Millisecond
   347  			if execProvider.Timeout < timeoutMinimum || execProvider.Timeout > timeoutMaximum {
   348  				return nil, fmt.Errorf("credentials: invalid `timeout_millis` field — executable timeout must be between %v and %v seconds", timeoutMinimum.Seconds(), timeoutMaximum.Seconds())
   349  			}
   350  		}
   351  		execProvider.OutputFile = ec.OutputFile
   352  		execProvider.client = o.Client
   353  		execProvider.opts = o
   354  		execProvider.env = runtimeEnvironment{}
   355  		return execProvider, nil
   356  	}
   357  	return nil, errors.New("credentials: unable to parse credential source")
   358  }
   359  
   360  func getGoogHeaderValue(conf *Options, p subjectTokenProvider) string {
   361  	return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
   362  		goVersion(),
   363  		"unknown",
   364  		p.providerType(),
   365  		conf.ServiceAccountImpersonationURL != "",
   366  		conf.ServiceAccountImpersonationLifetimeSeconds != 0)
   367  }
   368  

View as plain text