blob: 52c32589b723a6f475234a9b1a5f969a84b237da [file] [log] [blame]
// 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
}