From 4f8e0b2cef08407ea8e0ef389bd002240f99a318 Mon Sep 17 00:00:00 2001 From: Cody Oss <6331106+codyoss@users.noreply.github.com> Date: Mon, 3 May 2021 09:47:28 -0600 Subject: [PATCH] feat: add impersonate package (#927) (#991) 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. --- impersonate/doc.go | 32 +++++ impersonate/example_test.go | 97 ++++++++++++++ impersonate/idtoken.go | 129 +++++++++++++++++++ impersonate/idtoken_test.go | 83 ++++++++++++ impersonate/impersonate.go | 182 +++++++++++++++++++++++++++ impersonate/impersonate_test.go | 101 +++++++++++++++ impersonate/integration_test.go | 215 ++++++++++++++++++++++++++++++++ impersonate/user.go | 169 +++++++++++++++++++++++++ impersonate/user_test.go | 116 +++++++++++++++++ internal/kokoro/test.sh | 4 + option/option.go | 4 + 11 files changed, 1132 insertions(+) create mode 100644 impersonate/doc.go create mode 100644 impersonate/example_test.go create mode 100644 impersonate/idtoken.go create mode 100644 impersonate/idtoken_test.go create mode 100644 impersonate/impersonate.go create mode 100644 impersonate/impersonate_test.go create mode 100644 impersonate/integration_test.go create mode 100644 impersonate/user.go create mode 100644 impersonate/user_test.go diff --git a/impersonate/doc.go b/impersonate/doc.go new file mode 100644 index 00000000000..71c52071450 --- /dev/null +++ b/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 diff --git a/impersonate/example_test.go b/impersonate/example_test.go new file mode 100644 index 00000000000..937b7f6359d --- /dev/null +++ b/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/") +} diff --git a/impersonate/idtoken.go b/impersonate/idtoken.go new file mode 100644 index 00000000000..a2defff1518 --- /dev/null +++ b/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 +} diff --git a/impersonate/idtoken_test.go b/impersonate/idtoken_test.go new file mode 100644 index 00000000000..4a961822bdf --- /dev/null +++ b/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) + } + }) + } +} diff --git a/impersonate/impersonate.go b/impersonate/impersonate.go new file mode 100644 index 00000000000..602601007b7 --- /dev/null +++ b/impersonate/impersonate.go @@ -0,0 +1,182 @@ +// 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. 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.Seconds() > 3600 { + return nil, fmt.Errorf("impersonate: max lifetime is 3600s") + } + + 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 +} diff --git a/impersonate/impersonate_test.go b/impersonate/impersonate_test.go new file mode 100644 index 00000000000..a652cc630c2 --- /dev/null +++ b/impersonate/impersonate_test.go @@ -0,0 +1,101 @@ +// 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" + "strings" + "testing" + "time" + + "google.golang.org/api/option" +) + +func TestTokenSource_serviceAccount(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + targetPrincipal string + scopes []string + lifetime time.Duration + wantErr bool + }{ + { + name: "missing targetPrincipal", + wantErr: true, + }, + { + name: "missing scopes", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + wantErr: true, + }, + { + name: "lifetime over max", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + lifetime: 3601 * time.Second, + wantErr: true, + }, + { + name: "works", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + wantErr: false, + }, + } + + for _, tt := range tests { + name := tt.name + t.Run(name, func(t *testing.T) { + saTok := "sa-token" + client := &http.Client{ + Transport: RoundTripFn(func(req *http.Request) *http.Response { + if strings.Contains(req.URL.Path, "generateAccessToken") { + resp := generateAccessTokenResp{ + AccessToken: saTok, + ExpireTime: time.Now().Format(time.RFC3339), + } + 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: http.Header{}, + } + } + return nil + }), + } + ts, err := CredentialsTokenSource(ctx, CredentialsConfig{ + TargetPrincipal: tt.targetPrincipal, + Scopes: tt.scopes, + Lifetime: tt.lifetime, + }, 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 != saTok { + t.Fatalf("got %q, want %q", tok.AccessToken, saTok) + } + }) + } +} + +type RoundTripFn func(req *http.Request) *http.Response + +func (f RoundTripFn) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } diff --git a/impersonate/integration_test.go b/impersonate/integration_test.go new file mode 100644 index 00000000000..ec7cb644fff --- /dev/null +++ b/impersonate/integration_test.go @@ -0,0 +1,215 @@ +// Copyright 2021 Google LLC. +// 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" + "flag" + "fmt" + "math/rand" + "os" + "testing" + "time" + + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/idtoken" + "google.golang.org/api/impersonate" + "google.golang.org/api/option" + + "google.golang.org/api/storage/v1" +) + +var ( + baseKeyFile string + readerKeyFile string + readerEmail string + writerEmail string + projectID string + domain string + domainAdmin string +) + +func TestMain(m *testing.M) { + flag.Parse() + rand.Seed(time.Now().UnixNano()) + baseKeyFile = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + projectID = os.Getenv("GOOGLE_CLOUD_PROJECT") + readerKeyFile = os.Getenv("GCLOUD_TESTS_IMPERSONATE_READER_KEY") + readerEmail = os.Getenv("GCLOUD_TESTS_IMPERSONATE_READER_EMAIL") + writerEmail = os.Getenv("GCLOUD_TESTS_IMPERSONATE_WRITER_EMAIL") + domain = os.Getenv("GCLOUD_TESTS_IMPERSONATE_DOMAIN") + domainAdmin = os.Getenv("GCLOUD_TESTS_IMPERSONATE_DOMAIN_ADMIN") + + os.Exit(m.Run()) +} + +func validateEnvVars(t *testing.T) { + t.Helper() + if baseKeyFile == "" || + readerKeyFile == "" || + readerEmail == "" || + writerEmail == "" || + projectID == "" { + t.Skip("required environment variable not set, skipping") + } +} + +func TestCredentialsTokenSourceIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + validateEnvVars(t) + + ctx := context.Background() + tests := []struct { + name string + baseKeyFile string + delegates []string + }{ + { + name: "SA -> SA", + baseKeyFile: readerKeyFile, + }, + { + name: "SA -> Delegate -> SA", + baseKeyFile: baseKeyFile, + delegates: []string{readerEmail}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts, err := impersonate.CredentialsTokenSource(ctx, + impersonate.CredentialsConfig{ + TargetPrincipal: writerEmail, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + Delegates: tt.delegates, + }, + option.WithCredentialsFile(tt.baseKeyFile), + ) + if err != nil { + t.Fatalf("failed to create ts: %v", err) + } + svc, err := storage.NewService(ctx, option.WithTokenSource(ts)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + bucketName := fmt.Sprintf("%s-%d", projectID, rand.Int63()) + if _, err := svc.Buckets.Insert(projectID, &storage.Bucket{ + Name: bucketName, + }).Do(); err != nil { + t.Fatalf("error creating bucket: %v", err) + } + if err := svc.Buckets.Delete(bucketName).Do(); err != nil { + t.Fatalf("unable to cleanup bucket %q: %v", bucketName, err) + } + }) + } +} + +func TestIDTokenSourceIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + validateEnvVars(t) + + ctx := context.Background() + tests := []struct { + name string + baseKeyFile string + delegates []string + }{ + { + name: "SA -> SA", + baseKeyFile: readerKeyFile, + }, + { + name: "SA -> Delegate -> SA", + baseKeyFile: baseKeyFile, + delegates: []string{readerEmail}, + }, + } + + for _, tt := range tests { + name := tt.name + t.Run(name, func(t *testing.T) { + aud := "http://example.com/" + ts, err := impersonate.IDTokenSource(ctx, + impersonate.IDTokenConfig{ + TargetPrincipal: writerEmail, + Audience: aud, + Delegates: tt.delegates, + IncludeEmail: true, + }, + option.WithCredentialsFile(tt.baseKeyFile), + ) + if err != nil { + t.Fatalf("failed to create ts: %v", err) + } + tok, err := ts.Token() + if err != nil { + t.Fatalf("unable to retrieve Token: %v", err) + } + validTok, err := idtoken.Validate(context.Background(), tok.AccessToken, aud) + if err != nil { + t.Fatalf("token validation failed: %v", err) + } + if validTok.Audience != aud { + t.Fatalf("got %q, want %q", validTok.Audience, aud) + } + if validTok.Claims["email"] != writerEmail { + t.Fatalf("got %q, want %q", validTok.Claims["email"], writerEmail) + } + }) + } +} + +func TestTokenSourceIntegration_user(t *testing.T) { + t.Skip("https://github.com/googleapis/google-api-go-client/issues/948") + if testing.Short() { + t.Skip("skipping integration test") + } + validateEnvVars(t) + ctx := context.Background() + tests := []struct { + name string + baseKeyFile string + delegates []string + }{ + { + name: "SA -> SA", + baseKeyFile: readerKeyFile, + }, + { + name: "SA -> Delegate -> SA", + baseKeyFile: baseKeyFile, + delegates: []string{readerEmail}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts, err := impersonate.CredentialsTokenSource(ctx, + impersonate.CredentialsConfig{ + TargetPrincipal: writerEmail, + Delegates: tt.delegates, + Scopes: []string{"https://www.googleapis.com/auth/admin.directory.user", "https://www.googleapis.com/auth/admin.directory.group"}, + Subject: domainAdmin, + }, + option.WithCredentialsFile(baseKeyFile), + ) + if err != nil { + t.Fatalf("failed to create ts: %v", err) + } + svc, err := admin.NewService(ctx, option.WithTokenSource(ts)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + if _, err := svc.Users.List().Domain(domain).Do(); err != nil { + t.Fatalf("failed to list users: %v", err) + } + }) + } +} diff --git a/impersonate/user.go b/impersonate/user.go new file mode 100644 index 00000000000..059deab7117 --- /dev/null +++ b/impersonate/user.go @@ -0,0 +1,169 @@ +// 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" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +func user(ctx context.Context, c CredentialsConfig, client *http.Client, lifetime time.Duration, isStaticToken bool) (oauth2.TokenSource, error) { + u := userTokenSource{ + client: client, + targetPrincipal: c.TargetPrincipal, + subject: c.Subject, + lifetime: lifetime, + } + u.delegates = make([]string, len(c.Delegates)) + for i, v := range c.Delegates { + u.delegates[i] = formatIAMServiceAccountName(v) + } + u.scopes = make([]string, len(c.Scopes)) + copy(u.scopes, c.Scopes) + if isStaticToken { + tok, err := u.Token() + if err != nil { + return nil, err + } + return oauth2.StaticTokenSource(tok), nil + } + return oauth2.ReuseTokenSource(nil, u), nil +} + +type claimSet struct { + Iss string `json:"iss"` + Scope string `json:"scope,omitempty"` + Sub string `json:"sub,omitempty"` + Aud string `json:"aud"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` +} + +type signJWTRequest struct { + Payload string `json:"payload"` + Delegates []string `json:"delegates,omitempty"` +} + +type signJWTResponse struct { + // KeyID is the key used to sign the JWT. + KeyID string `json:"keyId"` + // SignedJwt contains the automatically generated header; the + // client-supplied payload; and the signature, which is generated using + // the key referenced by the `kid` field in the header. + SignedJWT string `json:"signedJwt"` +} + +type exchangeTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +type userTokenSource struct { + client *http.Client + + targetPrincipal string + subject string + scopes []string + lifetime time.Duration + delegates []string +} + +func (u userTokenSource) Token() (*oauth2.Token, error) { + signedJWT, err := u.signJWT() + if err != nil { + return nil, err + } + return u.exchangeToken(signedJWT) +} + +func (u userTokenSource) signJWT() (string, error) { + now := time.Now() + exp := now.Add(u.lifetime) + claims := claimSet{ + Iss: u.targetPrincipal, + Scope: strings.Join(u.scopes, " "), + Sub: u.subject, + Aud: fmt.Sprintf("%s/token", oauth2Endpoint), + Iat: now.Unix(), + Exp: exp.Unix(), + } + payloadBytes, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("impersonate: unable to marshal claims: %v", err) + } + signJWTReq := signJWTRequest{ + Payload: string(payloadBytes), + Delegates: u.delegates, + } + + bodyBytes, err := json.Marshal(signJWTReq) + if err != nil { + return "", fmt.Errorf("impersonate: unable to marshal request: %v", err) + } + reqURL := fmt.Sprintf("%s/v1/%s:signJwt", iamCredentailsEndpoint, formatIAMServiceAccountName(u.targetPrincipal)) + req, err := http.NewRequest("POST", reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("impersonate: unable to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + rawResp, err := u.client.Do(req) + if err != nil { + return "", fmt.Errorf("impersonate: unable to sign JWT: %v", err) + } + body, err := ioutil.ReadAll(io.LimitReader(rawResp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := rawResp.StatusCode; c < 200 || c > 299 { + return "", fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var signJWTResp signJWTResponse + if err := json.Unmarshal(body, &signJWTResp); err != nil { + return "", fmt.Errorf("impersonate: unable to parse response: %v", err) + } + return signJWTResp.SignedJWT, nil +} + +func (u userTokenSource) exchangeToken(signedJWT string) (*oauth2.Token, error) { + now := time.Now() + v := url.Values{} + v.Set("grant_type", "assertion") + v.Set("assertion_type", "http://oauth.net/grant_type/jwt/1.0/bearer") + v.Set("assertion", signedJWT) + rawResp, err := u.client.PostForm(fmt.Sprintf("%s/token", oauth2Endpoint), v) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to exchange token: %v", err) + } + body, err := ioutil.ReadAll(io.LimitReader(rawResp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := rawResp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var tokenResp exchangeTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) + } + + return &oauth2.Token{ + AccessToken: tokenResp.AccessToken, + TokenType: tokenResp.TokenType, + Expiry: now.Add(time.Second * time.Duration(tokenResp.ExpiresIn)), + }, nil +} diff --git a/impersonate/user_test.go b/impersonate/user_test.go new file mode 100644 index 00000000000..d21407a3b06 --- /dev/null +++ b/impersonate/user_test.go @@ -0,0 +1,116 @@ +// 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" + "strings" + "testing" + "time" + + "google.golang.org/api/option" +) + +func TestTokenSource_user(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + targetPrincipal string + scopes []string + lifetime time.Duration + subject string + wantErr bool + }{ + { + name: "missing targetPrincipal", + wantErr: true, + }, + { + name: "missing scopes", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + wantErr: true, + }, + { + name: "lifetime over max", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + lifetime: 3601 * time.Second, + wantErr: true, + }, + { + name: "works", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + subject: "admin@example.com", + wantErr: false, + }, + } + + for _, tt := range tests { + userTok := "user-token" + name := tt.name + t.Run(name, func(t *testing.T) { + client := &http.Client{ + Transport: RoundTripFn(func(req *http.Request) *http.Response { + if strings.Contains(req.URL.Path, "signJwt") { + resp := signJWTResponse{ + KeyID: "123", + SignedJWT: "jwt", + } + 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), + } + } + if strings.Contains(req.URL.Path, "/token") { + resp := exchangeTokenResponse{ + AccessToken: userTok, + TokenType: "Bearer", + ExpiresIn: int64(time.Hour.Seconds()), + } + 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), + } + } + return nil + }), + } + ts, err := CredentialsTokenSource(ctx, CredentialsConfig{ + TargetPrincipal: tt.targetPrincipal, + Scopes: tt.scopes, + Lifetime: tt.lifetime, + Subject: tt.subject, + }, 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 != userTok { + t.Fatalf("got %q, want %q", tok.AccessToken, userTok) + } + }) + } +} diff --git a/internal/kokoro/test.sh b/internal/kokoro/test.sh index 9c6dfea580d..3261cfab841 100755 --- a/internal/kokoro/test.sh +++ b/internal/kokoro/test.sh @@ -8,6 +8,10 @@ set -eo pipefail export GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/secret_manager/go-cloud-integration-service-account" +export GOOGLE_CLOUD_PROJECT="dulcet-port-762" +export GCLOUD_TESTS_IMPERSONATE_READER_KEY="${KOKORO_GFILE_DIR}/secret_manager/go-cloud-integration-impersonate-reader-service-account" +export GCLOUD_TESTS_IMPERSONATE_READER_EMAIL="impersonate-reader@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" +export GCLOUD_TESTS_IMPERSONATE_WRITER_EMAIL="impersonate-writer@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" # Display commands being run set -x diff --git a/option/option.go b/option/option.go index 686476f9cbb..9ff697e0b8e 100644 --- a/option/option.go +++ b/option/option.go @@ -305,6 +305,10 @@ func (w withClientCertSource) Apply(o *internal.DialSettings) { // roles/serviceusage.serviceUsageConsumer. // // This is an EXPERIMENTAL API and may be changed or removed in the future. +// +// This option has been replaced by `impersonate` package: +// `google.golang.org/api/impersonate`. Please use the `impersonate` package +// instead. func ImpersonateCredentials(target string, delegates ...string) ClientOption { return impersonateServiceAccount{ target: target,