externalaccount.go

  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
 15package externalaccount
 16
 17import (
 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
 35const (
 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
 44var (
 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.
 53type 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.
118type 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.
128type 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.
142type 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.
154type 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
164func (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.
198func (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.
217func (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.
229func 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
274type 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.
280type tokenProvider struct {
281	client *http.Client
282	logger *slog.Logger
283	opts   *Options
284	stp    subjectTokenProvider
285}
286
287func (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
344func 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{}, nil
417	}
418	return nil, errors.New("credentials: unable to parse credential source")
419}
420
421func getGoogHeaderValue(conf *Options, p subjectTokenProvider) string {
422	return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
423		goVersion(),
424		"unknown",
425		p.providerType(),
426		conf.ServiceAccountImpersonationURL != "",
427		conf.ServiceAccountImpersonationLifetimeSeconds != 0)
428}