externalaccount.go raw

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