// Copyright 2021 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package impersonate import ( "bytes" "context" "encoding/json" "fmt" "io" "io/ioutil" "net/http" "time" "golang.org/x/oauth2" "google.golang.org/api/option" "google.golang.org/api/option/internaloption" htransport "google.golang.org/api/transport/http" ) var ( iamCredentailsEndpoint = "https://iamcredentials.googleapis.com" oauth2Endpoint = "https://oauth2.googleapis.com" ) // CredentialsConfig for generating impersonated credentials. type CredentialsConfig struct { // TargetPrincipal is the email address of the service account to // impersonate. Required. TargetPrincipal string // Scopes that the impersonated credential should have. Required. Scopes []string // Delegates are the service account email addresses in a delegation chain. // Each service account must be granted roles/iam.serviceAccountTokenCreator // on the next service account in the chain. Optional. Delegates []string // Lifetime is the amount of time until the impersonated token expires. If // unset the token's lifetime will be one hour and be automatically // refreshed. If set the token may have a max lifetime of one hour and will // not be refreshed. Service accounts that have been added to an org policy // with constraints/iam.allowServiceAccountCredentialLifetimeExtension may // request a token lifetime of up to 12 hours. Optional. Lifetime time.Duration // Subject is the sub field of a JWT. This field should only be set if you // wish to impersonate as a user. This feature is useful when using domain // wide delegation. Optional. Subject string } // defaultClientOptions ensures the base credentials will work with the IAM // Credentials API if no scope or audience is set by the user. func defaultClientOptions() []option.ClientOption { return []option.ClientOption{ internaloption.WithDefaultAudience("https://iamcredentials.googleapis.com/"), internaloption.WithDefaultScopes("https://www.googleapis.com/auth/cloud-platform"), } } // CredentialsTokenSource returns an impersonated CredentialsTokenSource configured with the provided // config and using credentials loaded from Application Default Credentials as // the base credentials. func CredentialsTokenSource(ctx context.Context, config CredentialsConfig, opts ...option.ClientOption) (oauth2.TokenSource, error) { if config.TargetPrincipal == "" { return nil, fmt.Errorf("impersonate: a target service account must be provided") } if len(config.Scopes) == 0 { return nil, fmt.Errorf("impersonate: scopes must be provided") } if config.Lifetime.Hours() > 12 { return nil, fmt.Errorf("impersonate: max lifetime is 12 hours") } var isStaticToken bool // Default to the longest acceptable value of one hour as the token will // be refreshed automatically if not set. lifetime := 3600 * time.Second if config.Lifetime != 0 { lifetime = config.Lifetime // Don't auto-refresh token if a lifetime is configured. isStaticToken = true } clientOpts := append(defaultClientOptions(), opts...) client, _, err := htransport.NewClient(ctx, clientOpts...) if err != nil { return nil, err } // If a subject is specified a different auth-flow is initiated to // impersonate as the provided subject (user). if config.Subject != "" { return user(ctx, config, client, lifetime, isStaticToken) } its := impersonatedTokenSource{ client: client, targetPrincipal: config.TargetPrincipal, lifetime: fmt.Sprintf("%.fs", lifetime.Seconds()), } for _, v := range config.Delegates { its.delegates = append(its.delegates, formatIAMServiceAccountName(v)) } its.scopes = make([]string, len(config.Scopes)) copy(its.scopes, config.Scopes) if isStaticToken { tok, err := its.Token() if err != nil { return nil, err } return oauth2.StaticTokenSource(tok), nil } return oauth2.ReuseTokenSource(nil, its), nil } func formatIAMServiceAccountName(name string) string { return fmt.Sprintf("projects/-/serviceAccounts/%s", name) } type generateAccessTokenReq struct { Delegates []string `json:"delegates,omitempty"` Lifetime string `json:"lifetime,omitempty"` Scope []string `json:"scope,omitempty"` } type generateAccessTokenResp struct { AccessToken string `json:"accessToken"` ExpireTime string `json:"expireTime"` } type impersonatedTokenSource struct { client *http.Client targetPrincipal string lifetime string scopes []string delegates []string } // Token returns an impersonated Token. func (i impersonatedTokenSource) Token() (*oauth2.Token, error) { reqBody := generateAccessTokenReq{ Delegates: i.delegates, Lifetime: i.lifetime, Scope: i.scopes, } b, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err) } url := fmt.Sprintf("%s/v1/%s:generateAccessToken", iamCredentailsEndpoint, formatIAMServiceAccountName(i.targetPrincipal)) req, err := http.NewRequest("POST", url, bytes.NewReader(b)) if err != nil { return nil, fmt.Errorf("impersonate: unable to create request: %v", err) } req.Header.Set("Content-Type", "application/json") resp, err := i.client.Do(req) if err != nil { return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err) } defer resp.Body.Close() body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("impersonate: unable to read body: %v", err) } if c := resp.StatusCode; c < 200 || c > 299 { return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) } var accessTokenResp generateAccessTokenResp if err := json.Unmarshal(body, &accessTokenResp); err != nil { return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) } expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime) if err != nil { return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err) } return &oauth2.Token{ AccessToken: accessTokenResp.AccessToken, Expiry: expiry, }, nil }