impersonate.go raw

   1  // Copyright 2021 The Go Authors. All rights reserved.
   2  // Use of this source code is governed by a BSD-style
   3  // license that can be found in the LICENSE file.
   4  
   5  package impersonate
   6  
   7  import (
   8  	"bytes"
   9  	"context"
  10  	"encoding/json"
  11  	"errors"
  12  	"fmt"
  13  	"io"
  14  	"net/http"
  15  	"time"
  16  
  17  	"golang.org/x/oauth2"
  18  	"google.golang.org/api/internal"
  19  	"google.golang.org/api/option"
  20  	"google.golang.org/api/option/internaloption"
  21  	htransport "google.golang.org/api/transport/http"
  22  )
  23  
  24  var (
  25  	iamCredentailsEndpoint                      = "https://iamcredentials.googleapis.com"
  26  	oauth2Endpoint                              = "https://oauth2.googleapis.com"
  27  	errMissingTargetPrincipal                   = errors.New("impersonate: a target service account must be provided")
  28  	errMissingScopes                            = errors.New("impersonate: scopes must be provided")
  29  	errLifetimeOverMax                          = errors.New("impersonate: max lifetime is 12 hours")
  30  	errUniverseNotSupportedDomainWideDelegation = errors.New("impersonate: service account user is configured for the credential. " +
  31  		"Domain-wide delegation is not supported in universes other than googleapis.com")
  32  )
  33  
  34  // CredentialsConfig for generating impersonated credentials.
  35  type CredentialsConfig struct {
  36  	// TargetPrincipal is the email address of the service account to
  37  	// impersonate. Required.
  38  	TargetPrincipal string
  39  	// Scopes that the impersonated credential should have. Required.
  40  	Scopes []string
  41  	// Delegates are the service account email addresses in a delegation chain.
  42  	// Each service account must be granted roles/iam.serviceAccountTokenCreator
  43  	// on the next service account in the chain. Optional.
  44  	Delegates []string
  45  	// Lifetime is the amount of time until the impersonated token expires. If
  46  	// unset the token's lifetime will be one hour and be automatically
  47  	// refreshed. If set the token may have a max lifetime of one hour and will
  48  	// not be refreshed. Service accounts that have been added to an org policy
  49  	// with constraints/iam.allowServiceAccountCredentialLifetimeExtension may
  50  	// request a token lifetime of up to 12 hours. Optional.
  51  	Lifetime time.Duration
  52  	// Subject is the sub field of a JWT. This field should only be set if you
  53  	// wish to impersonate as a user. This feature is useful when using domain
  54  	// wide delegation. Optional.
  55  	Subject string
  56  }
  57  
  58  // defaultClientOptions ensures the base credentials will work with the IAM
  59  // Credentials API if no scope or audience is set by the user.
  60  func defaultClientOptions() []option.ClientOption {
  61  	return []option.ClientOption{
  62  		internaloption.WithDefaultAudience("https://iamcredentials.googleapis.com/"),
  63  		internaloption.WithDefaultScopes("https://www.googleapis.com/auth/cloud-platform"),
  64  	}
  65  }
  66  
  67  // CredentialsTokenSource returns an impersonated CredentialsTokenSource configured with the provided
  68  // config and using credentials loaded from Application Default Credentials as
  69  // the base credentials.
  70  func CredentialsTokenSource(ctx context.Context, config CredentialsConfig, opts ...option.ClientOption) (oauth2.TokenSource, error) {
  71  	if config.TargetPrincipal == "" {
  72  		return nil, errMissingTargetPrincipal
  73  	}
  74  	if len(config.Scopes) == 0 {
  75  		return nil, errMissingScopes
  76  	}
  77  	if config.Lifetime.Hours() > 12 {
  78  		return nil, errLifetimeOverMax
  79  	}
  80  
  81  	var isStaticToken bool
  82  	// Default to the longest acceptable value of one hour as the token will
  83  	// be refreshed automatically if not set.
  84  	lifetime := 3600 * time.Second
  85  	if config.Lifetime != 0 {
  86  		lifetime = config.Lifetime
  87  		// Don't auto-refresh token if a lifetime is configured.
  88  		isStaticToken = true
  89  	}
  90  
  91  	clientOpts := append(defaultClientOptions(), opts...)
  92  	client, _, err := htransport.NewClient(ctx, clientOpts...)
  93  	if err != nil {
  94  		return nil, err
  95  	}
  96  	// If a subject is specified a domain-wide delegation auth-flow is initiated
  97  	// to impersonate as the provided subject (user).
  98  	if config.Subject != "" {
  99  		settings, err := newSettings(clientOpts)
 100  		if err != nil {
 101  			return nil, err
 102  		}
 103  		if !settings.IsUniverseDomainGDU() {
 104  			return nil, errUniverseNotSupportedDomainWideDelegation
 105  		}
 106  		return user(ctx, config, client, lifetime, isStaticToken)
 107  	}
 108  
 109  	its := impersonatedTokenSource{
 110  		client:          client,
 111  		targetPrincipal: config.TargetPrincipal,
 112  		lifetime:        fmt.Sprintf("%.fs", lifetime.Seconds()),
 113  	}
 114  	for _, v := range config.Delegates {
 115  		its.delegates = append(its.delegates, formatIAMServiceAccountName(v))
 116  	}
 117  	its.scopes = make([]string, len(config.Scopes))
 118  	copy(its.scopes, config.Scopes)
 119  
 120  	if isStaticToken {
 121  		tok, err := its.Token()
 122  		if err != nil {
 123  			return nil, err
 124  		}
 125  		return oauth2.StaticTokenSource(tok), nil
 126  	}
 127  	return oauth2.ReuseTokenSource(nil, its), nil
 128  }
 129  
 130  func newSettings(opts []option.ClientOption) (*internal.DialSettings, error) {
 131  	var o internal.DialSettings
 132  	for _, opt := range opts {
 133  		opt.Apply(&o)
 134  	}
 135  	if err := o.Validate(); err != nil {
 136  		return nil, err
 137  	}
 138  
 139  	return &o, nil
 140  }
 141  
 142  func formatIAMServiceAccountName(name string) string {
 143  	return fmt.Sprintf("projects/-/serviceAccounts/%s", name)
 144  }
 145  
 146  type generateAccessTokenReq struct {
 147  	Delegates []string `json:"delegates,omitempty"`
 148  	Lifetime  string   `json:"lifetime,omitempty"`
 149  	Scope     []string `json:"scope,omitempty"`
 150  }
 151  
 152  type generateAccessTokenResp struct {
 153  	AccessToken string `json:"accessToken"`
 154  	ExpireTime  string `json:"expireTime"`
 155  }
 156  
 157  type impersonatedTokenSource struct {
 158  	client *http.Client
 159  
 160  	targetPrincipal string
 161  	lifetime        string
 162  	scopes          []string
 163  	delegates       []string
 164  }
 165  
 166  // Token returns an impersonated Token.
 167  func (i impersonatedTokenSource) Token() (*oauth2.Token, error) {
 168  	reqBody := generateAccessTokenReq{
 169  		Delegates: i.delegates,
 170  		Lifetime:  i.lifetime,
 171  		Scope:     i.scopes,
 172  	}
 173  	b, err := json.Marshal(reqBody)
 174  	if err != nil {
 175  		return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err)
 176  	}
 177  	url := fmt.Sprintf("%s/v1/%s:generateAccessToken", iamCredentailsEndpoint, formatIAMServiceAccountName(i.targetPrincipal))
 178  	req, err := http.NewRequest("POST", url, bytes.NewReader(b))
 179  	if err != nil {
 180  		return nil, fmt.Errorf("impersonate: unable to create request: %v", err)
 181  	}
 182  	req.Header.Set("Content-Type", "application/json")
 183  
 184  	resp, err := i.client.Do(req)
 185  	if err != nil {
 186  		return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err)
 187  	}
 188  	defer resp.Body.Close()
 189  	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
 190  	if err != nil {
 191  		return nil, fmt.Errorf("impersonate: unable to read body: %v", err)
 192  	}
 193  	if c := resp.StatusCode; c < 200 || c > 299 {
 194  		return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
 195  	}
 196  
 197  	var accessTokenResp generateAccessTokenResp
 198  	if err := json.Unmarshal(body, &accessTokenResp); err != nil {
 199  		return nil, fmt.Errorf("impersonate: unable to parse response: %v", err)
 200  	}
 201  	expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
 202  	if err != nil {
 203  		return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err)
 204  	}
 205  	return &oauth2.Token{
 206  		AccessToken: accessTokenResp.AccessToken,
 207  		Expiry:      expiry,
 208  	}, nil
 209  }
 210