...

Source file src/cloud.google.com/go/auth/credentials/impersonate/impersonate.go

Documentation: cloud.google.com/go/auth/credentials/impersonate

     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 impersonate
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"net/http"
    24  	"time"
    25  
    26  	"cloud.google.com/go/auth"
    27  	"cloud.google.com/go/auth/credentials"
    28  	"cloud.google.com/go/auth/httptransport"
    29  	"cloud.google.com/go/auth/internal"
    30  )
    31  
    32  var (
    33  	iamCredentialsEndpoint                      = "https://iamcredentials.googleapis.com"
    34  	oauth2Endpoint                              = "https://oauth2.googleapis.com"
    35  	errMissingTargetPrincipal                   = errors.New("impersonate: target service account must be provided")
    36  	errMissingScopes                            = errors.New("impersonate: scopes must be provided")
    37  	errLifetimeOverMax                          = errors.New("impersonate: max lifetime is 12 hours")
    38  	errUniverseNotSupportedDomainWideDelegation = errors.New("impersonate: service account user is configured for the credential. " +
    39  		"Domain-wide delegation is not supported in universes other than googleapis.com")
    40  )
    41  
    42  // TODO(codyoss): plumb through base for this and idtoken
    43  
    44  // NewCredentials returns an impersonated
    45  // [cloud.google.com/go/auth/NewCredentials] configured with the provided options
    46  // and using credentials loaded from Application Default Credentials as the base
    47  // credentials if not provided with the opts.
    48  func NewCredentials(opts *CredentialsOptions) (*auth.Credentials, error) {
    49  	if err := opts.validate(); err != nil {
    50  		return nil, err
    51  	}
    52  
    53  	var isStaticToken bool
    54  	// Default to the longest acceptable value of one hour as the token will
    55  	// be refreshed automatically if not set.
    56  	lifetime := 1 * time.Hour
    57  	if opts.Lifetime != 0 {
    58  		lifetime = opts.Lifetime
    59  		// Don't auto-refresh token if a lifetime is configured.
    60  		isStaticToken = true
    61  	}
    62  
    63  	var client *http.Client
    64  	var creds *auth.Credentials
    65  	if opts.Client == nil && opts.Credentials == nil {
    66  		var err error
    67  		creds, err = credentials.DetectDefault(&credentials.DetectOptions{
    68  			Scopes:           []string{defaultScope},
    69  			UseSelfSignedJWT: true,
    70  		})
    71  		if err != nil {
    72  			return nil, err
    73  		}
    74  		client, err = httptransport.NewClient(&httptransport.Options{
    75  			Credentials: creds,
    76  		})
    77  		if err != nil {
    78  			return nil, err
    79  		}
    80  	} else if opts.Credentials != nil {
    81  		creds = opts.Credentials
    82  		client = internal.CloneDefaultClient()
    83  		if err := httptransport.AddAuthorizationMiddleware(client, opts.Credentials); err != nil {
    84  			return nil, err
    85  		}
    86  	} else {
    87  		client = opts.Client
    88  	}
    89  
    90  	// If a subject is specified a domain-wide delegation auth-flow is initiated
    91  	// to impersonate as the provided subject (user).
    92  	if opts.Subject != "" {
    93  		if !opts.isUniverseDomainGDU() {
    94  			return nil, errUniverseNotSupportedDomainWideDelegation
    95  		}
    96  		tp, err := user(opts, client, lifetime, isStaticToken)
    97  		if err != nil {
    98  			return nil, err
    99  		}
   100  		var udp auth.CredentialsPropertyProvider
   101  		if creds != nil {
   102  			udp = auth.CredentialsPropertyFunc(creds.UniverseDomain)
   103  		}
   104  		return auth.NewCredentials(&auth.CredentialsOptions{
   105  			TokenProvider:          tp,
   106  			UniverseDomainProvider: udp,
   107  		}), nil
   108  	}
   109  
   110  	its := impersonatedTokenProvider{
   111  		client:          client,
   112  		targetPrincipal: opts.TargetPrincipal,
   113  		lifetime:        fmt.Sprintf("%.fs", lifetime.Seconds()),
   114  	}
   115  	for _, v := range opts.Delegates {
   116  		its.delegates = append(its.delegates, formatIAMServiceAccountName(v))
   117  	}
   118  	its.scopes = make([]string, len(opts.Scopes))
   119  	copy(its.scopes, opts.Scopes)
   120  
   121  	var tpo *auth.CachedTokenProviderOptions
   122  	if isStaticToken {
   123  		tpo = &auth.CachedTokenProviderOptions{
   124  			DisableAutoRefresh: true,
   125  		}
   126  	}
   127  
   128  	var udp auth.CredentialsPropertyProvider
   129  	if creds != nil {
   130  		udp = auth.CredentialsPropertyFunc(creds.UniverseDomain)
   131  	}
   132  	return auth.NewCredentials(&auth.CredentialsOptions{
   133  		TokenProvider:          auth.NewCachedTokenProvider(its, tpo),
   134  		UniverseDomainProvider: udp,
   135  	}), nil
   136  }
   137  
   138  // CredentialsOptions for generating an impersonated credential token.
   139  type CredentialsOptions struct {
   140  	// TargetPrincipal is the email address of the service account to
   141  	// impersonate. Required.
   142  	TargetPrincipal string
   143  	// Scopes that the impersonated credential should have. Required.
   144  	Scopes []string
   145  	// Delegates are the service account email addresses in a delegation chain.
   146  	// Each service account must be granted roles/iam.serviceAccountTokenCreator
   147  	// on the next service account in the chain. Optional.
   148  	Delegates []string
   149  	// Lifetime is the amount of time until the impersonated token expires. If
   150  	// unset the token's lifetime will be one hour and be automatically
   151  	// refreshed. If set the token may have a max lifetime of one hour and will
   152  	// not be refreshed. Service accounts that have been added to an org policy
   153  	// with constraints/iam.allowServiceAccountCredentialLifetimeExtension may
   154  	// request a token lifetime of up to 12 hours. Optional.
   155  	Lifetime time.Duration
   156  	// Subject is the sub field of a JWT. This field should only be set if you
   157  	// wish to impersonate as a user. This feature is useful when using domain
   158  	// wide delegation. Optional.
   159  	Subject string
   160  
   161  	// Credentials is the provider of the credentials used to fetch the ID
   162  	// token. If not provided, and a Client is also not provided, credentials
   163  	// will try to be detected from the environment. Optional.
   164  	Credentials *auth.Credentials
   165  	// Client configures the underlying client used to make network requests
   166  	// when fetching tokens. If provided the client should provide it's own
   167  	// credentials at call time. Optional.
   168  	Client *http.Client
   169  	// UniverseDomain is the default service domain for a given Cloud universe.
   170  	// The default value is "googleapis.com". Optional.
   171  	UniverseDomain string
   172  }
   173  
   174  func (o *CredentialsOptions) validate() error {
   175  	if o == nil {
   176  		return errors.New("impersonate: options must be provided")
   177  	}
   178  	if o.TargetPrincipal == "" {
   179  		return errMissingTargetPrincipal
   180  	}
   181  	if len(o.Scopes) == 0 {
   182  		return errMissingScopes
   183  	}
   184  	if o.Lifetime.Hours() > 12 {
   185  		return errLifetimeOverMax
   186  	}
   187  	return nil
   188  }
   189  
   190  // getUniverseDomain is the default service domain for a given Cloud universe.
   191  // The default value is "googleapis.com".
   192  func (o *CredentialsOptions) getUniverseDomain() string {
   193  	if o.UniverseDomain == "" {
   194  		return internal.DefaultUniverseDomain
   195  	}
   196  	return o.UniverseDomain
   197  }
   198  
   199  // isUniverseDomainGDU returns true if the universe domain is the default Google
   200  // universe.
   201  func (o *CredentialsOptions) isUniverseDomainGDU() bool {
   202  	return o.getUniverseDomain() == internal.DefaultUniverseDomain
   203  }
   204  
   205  func formatIAMServiceAccountName(name string) string {
   206  	return fmt.Sprintf("projects/-/serviceAccounts/%s", name)
   207  }
   208  
   209  type generateAccessTokenRequest struct {
   210  	Delegates []string `json:"delegates,omitempty"`
   211  	Lifetime  string   `json:"lifetime,omitempty"`
   212  	Scope     []string `json:"scope,omitempty"`
   213  }
   214  
   215  type generateAccessTokenResponse struct {
   216  	AccessToken string `json:"accessToken"`
   217  	ExpireTime  string `json:"expireTime"`
   218  }
   219  
   220  type impersonatedTokenProvider struct {
   221  	client *http.Client
   222  
   223  	targetPrincipal string
   224  	lifetime        string
   225  	scopes          []string
   226  	delegates       []string
   227  }
   228  
   229  // Token returns an impersonated Token.
   230  func (i impersonatedTokenProvider) Token(ctx context.Context) (*auth.Token, error) {
   231  	reqBody := generateAccessTokenRequest{
   232  		Delegates: i.delegates,
   233  		Lifetime:  i.lifetime,
   234  		Scope:     i.scopes,
   235  	}
   236  	b, err := json.Marshal(reqBody)
   237  	if err != nil {
   238  		return nil, fmt.Errorf("impersonate: unable to marshal request: %w", err)
   239  	}
   240  	url := fmt.Sprintf("%s/v1/%s:generateAccessToken", iamCredentialsEndpoint, formatIAMServiceAccountName(i.targetPrincipal))
   241  	req, err := http.NewRequest("POST", url, bytes.NewReader(b))
   242  	if err != nil {
   243  		return nil, fmt.Errorf("impersonate: unable to create request: %w", err)
   244  	}
   245  	req.Header.Set("Content-Type", "application/json")
   246  
   247  	resp, err := i.client.Do(req)
   248  	if err != nil {
   249  		return nil, fmt.Errorf("impersonate: unable to generate access token: %w", err)
   250  	}
   251  	defer resp.Body.Close()
   252  	body, err := internal.ReadAll(resp.Body)
   253  	if err != nil {
   254  		return nil, fmt.Errorf("impersonate: unable to read body: %w", err)
   255  	}
   256  	if c := resp.StatusCode; c < 200 || c > 299 {
   257  		return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
   258  	}
   259  
   260  	var accessTokenResp generateAccessTokenResponse
   261  	if err := json.Unmarshal(body, &accessTokenResp); err != nil {
   262  		return nil, fmt.Errorf("impersonate: unable to parse response: %w", err)
   263  	}
   264  	expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
   265  	if err != nil {
   266  		return nil, fmt.Errorf("impersonate: unable to parse expiry: %w", err)
   267  	}
   268  	return &auth.Token{
   269  		Value:  accessTokenResp.AccessToken,
   270  		Expiry: expiry,
   271  	}, nil
   272  }
   273  

View as plain text