Skip to content

Commit

Permalink
feat: add impersonate package (#927) (#991)
Browse files Browse the repository at this point in the history
This package is used for creating an impersonated TokenSource. This
TokenSource can then be passed into any client or API that accepts
a client option. This package provides support for three types of
impersonation:

1. A service account impersonating another service account.
2. A service account impersonating another service account as an
   admin user -- a pattern used with domain wide delegation.
3. A service account creating an impersonated ID token.
  • Loading branch information
codyoss committed May 3, 2021
1 parent 57b4a83 commit 4f8e0b2
Show file tree
Hide file tree
Showing 11 changed files with 1,132 additions and 0 deletions.
32 changes: 32 additions & 0 deletions impersonate/doc.go
@@ -0,0 +1,32 @@
// 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 is used to impersonate Google Credentials.
//
// Required IAM roles
//
// In order to impersonate a service account the base service account must have
// the Service Account Token Creator role, roles/iam.serviceAccountTokenCreator,
// on the service account being impersonated. See
// https://cloud.google.com/iam/docs/understanding-service-accounts.
//
// Optionally, delegates can be used during impersonation if the base service
// account lacks the token creator role on the target. When using delegates,
// each service account must be granted roles/iam.serviceAccountTokenCreator
// on the next service account in the delgation chain.
//
// For example, if a base service account of SA1 is trying to impersonate target
// service account SA2 while using delegate service accounts DSA1 and DSA2,
// the following must be true:
//
// 1. Base service account SA1 has roles/iam.serviceAccountTokenCreator on
// DSA1.
// 2. DSA1 has roles/iam.serviceAccountTokenCreator on DSA2.
// 3. DSA2 has roles/iam.serviceAccountTokenCreator on target SA2.
//
// If the base credential is an authorized user and not a service account, or if
// the option WithQuotaProject is set, the target service account must have a
// role that grants the serviceusage.services.use permission such as
// roles/serviceusage.serviceUsageConsumer.
package impersonate
97 changes: 97 additions & 0 deletions impersonate/example_test.go
@@ -0,0 +1,97 @@
// 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_test

import (
"context"
"log"

admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
"google.golang.org/api/secretmanager/v1"
"google.golang.org/api/transport"
)

func ExampleCredentialsTokenSource_serviceAccount() {
ctx := context.Background()

// Base credentials sourced from ADC or provided client options.
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
// Optionally supply delegates.
Delegates: []string{"bar@project-id.iam.gserviceaccount.com"},
})
if err != nil {
log.Fatal(err)
}

// Pass an impersonated credential to any function that takes client
// options.
client, err := secretmanager.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Projects.Secrets.Get("...")
}

func ExampleCredentialsTokenSource_adminUser() {
ctx := context.Background()

// Base credentials sourced from ADC or provided client options.
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
// Optionally supply delegates.
Delegates: []string{"bar@project-id.iam.gserviceaccount.com"},
// Specify user to impersonate
Subject: "admin@example.com",
})
if err != nil {
log.Fatal(err)
}

// Pass an impersonated credential to any function that takes client
// options.
client, err := admin.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Groups.Delete("...")
}

func ExampleIDTokenSource() {
ctx := context.Background()

// Base credentials sourced from ADC or provided client options.
ts, err := impersonate.IDTokenSource(ctx, impersonate.IDTokenConfig{
Audience: "http://example.com/",
TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
IncludeEmail: true,
// Optionally supply delegates.
Delegates: []string{"bar@project-id.iam.gserviceaccount.com"},
})
if err != nil {
log.Fatal(err)
}

// Pass an impersonated credential to any function that takes client
// options.
client, _, err := transport.NewHTTPClient(ctx, option.WithTokenSource(ts))
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Get("http://example.com/")
}
129 changes: 129 additions & 0 deletions impersonate/idtoken.go
@@ -0,0 +1,129 @@
// 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"
htransport "google.golang.org/api/transport/http"
)

// IDTokenConfig for generating an impersonated ID token.
type IDTokenConfig struct {
// Audience is the `aud` field for the token, such as an API endpoint the
// token will grant access to. Required.
Audience string
// TargetPrincipal is the email address of the service account to
// impersonate. Required.
TargetPrincipal string
// IncludeEmail includes the service account's email in the token. The
// resulting token will include both an `email` and `email_verified`
// claim.
IncludeEmail bool
// 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
}

// IDTokenSource creates an impersonated TokenSource that returns ID tokens
// configured with the provided config and using credentials loaded from
// Application Default Credentials as the base credentials. The tokens provided
// by the source are valid for one hour and are automatically refreshed.
func IDTokenSource(ctx context.Context, config IDTokenConfig, opts ...option.ClientOption) (oauth2.TokenSource, error) {
if config.Audience == "" {
return nil, fmt.Errorf("impersonate: an audience must be provided")
}
if config.TargetPrincipal == "" {
return nil, fmt.Errorf("impersonate: a target service account must be provided")
}

clientOpts := append(defaultClientOptions(), opts...)
client, _, err := htransport.NewClient(ctx, clientOpts...)
if err != nil {
return nil, err
}

its := impersonatedIDTokenSource{
client: client,
targetPrincipal: config.TargetPrincipal,
audience: config.Audience,
includeEmail: config.IncludeEmail,
}
for _, v := range config.Delegates {
its.delegates = append(its.delegates, formatIAMServiceAccountName(v))
}
return oauth2.ReuseTokenSource(nil, its), nil
}

type generateIDTokenRequest struct {
Audience string `json:"audience"`
IncludeEmail bool `json:"includeEmail"`
Delegates []string `json:"delegates,omitempty"`
}

type generateIDTokenResponse struct {
Token string `json:"token"`
}

type impersonatedIDTokenSource struct {
client *http.Client

targetPrincipal string
audience string
includeEmail bool
delegates []string
}

func (i impersonatedIDTokenSource) Token() (*oauth2.Token, error) {
now := time.Now()
genIDTokenReq := generateIDTokenRequest{
Audience: i.audience,
IncludeEmail: i.includeEmail,
Delegates: i.delegates,
}
bodyBytes, err := json.Marshal(genIDTokenReq)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err)
}

url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentailsEndpoint, formatIAMServiceAccountName(i.targetPrincipal))
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
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 ID 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 generateIDTokenResp generateIDTokenResponse
if err := json.Unmarshal(body, &generateIDTokenResp); err != nil {
return nil, fmt.Errorf("impersonate: unable to parse response: %v", err)
}
return &oauth2.Token{
AccessToken: generateIDTokenResp.Token,
// Generated ID tokens are good for one hour.
Expiry: now.Add(1 * time.Hour),
}, nil
}
83 changes: 83 additions & 0 deletions impersonate/idtoken_test.go
@@ -0,0 +1,83 @@
// 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"
"io/ioutil"
"net/http"
"testing"

"google.golang.org/api/option"
)

func TestIDTokenSource(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
aud string
targetPrincipal string
wantErr bool
}{
{
name: "missing aud",
targetPrincipal: "foo@project-id.iam.gserviceaccount.com",
wantErr: true,
},
{
name: "missing targetPrincipal",
aud: "http://example.com/",
wantErr: true,
},
{
name: "works",
aud: "http://example.com/",
targetPrincipal: "foo@project-id.iam.gserviceaccount.com",
wantErr: false,
},
}

for _, tt := range tests {
name := tt.name
t.Run(name, func(t *testing.T) {
idTok := "id-token"
client := &http.Client{
Transport: RoundTripFn(func(req *http.Request) *http.Response {
resp := generateIDTokenResponse{
Token: idTok,
}
b, err := json.Marshal(&resp)
if err != nil {
t.Fatalf("unable to marshal response: %v", err)
}
return &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewReader(b)),
Header: make(http.Header),
}
}),
}
ts, err := IDTokenSource(ctx, IDTokenConfig{
Audience: tt.aud,
TargetPrincipal: tt.targetPrincipal,
}, option.WithHTTPClient(client))
if tt.wantErr && err != nil {
return
}
if err != nil {
t.Fatal(err)
}
tok, err := ts.Token()
if err != nil {
t.Fatal(err)
}
if tok.AccessToken != idTok {
t.Fatalf("got %q, want %q", tok.AccessToken, idTok)
}
})
}
}

0 comments on commit 4f8e0b2

Please sign in to comment.